diff --git a/.fastly/config.toml b/.fastly/config.toml new file mode 100644 index 000000000..c98e6227c --- /dev/null +++ b/.fastly/config.toml @@ -0,0 +1,28 @@ +config_version = 6 + +[fastly] +account_endpoint = "https://accounts.fastly.com" +api_endpoint = "https://api.fastly.com" + +[wasm-metadata] +build_info = "enable" +machine_info = "disable" # users have to opt-in for this (everything else they'll have to opt-out) +package_info = "enable" +script_info = "enable" + +[language] +[language.go] +tinygo_constraint = ">= 0.28.1-0" # NOTE -0 indicates to the CLI's semver package that we accept pre-releases (TinyGo users commonly use pre-releases). +tinygo_constraint_fallback = ">= 0.26.0-0" # The Fastly Go SDK 0.2.0 requires `tinygo_constraint` but the 0.1.x SDK requires this constraint. +toolchain_constraint = ">= 1.21" # Go toolchain constraint for use with WASI support. +toolchain_constraint_tinygo = ">= 1.18" # Go toolchain constraint for use with TinyGo. + +[language.rust] +toolchain_constraint = ">= 1.78.0" +wasm_wasi_target = "wasm32-wasip1" + +[wasm-tools] +ttl = "24h" + +[viceroy] +ttl = "24h" diff --git a/.fastly/help/README.md b/.fastly/help/README.md new file mode 100644 index 000000000..a9870af7a --- /dev/null +++ b/.fastly/help/README.md @@ -0,0 +1,18 @@ +# Developer Hub Help Pages + +This directory contains troubleshooting pages for common issues in this project, which are ingested by the [Developer Hub](https://fastly.com/documentation/developers) and served on the `fastly.help` domain. + +To update or create a help page, add or edit the Markdown files in this directory. Changes will be deployed on Developer Hub within 24 hours. + +## Example Page + +```md +--- +id: ecp-feature +title: Compute is not enabled on your account +template: help +--- + +Our edge compute platform is in limited availability and not yet available to all customers. Contact [Fastly Support](https://support.fastly.com/) or your account manager to have the feature enabled on your account. + +``` diff --git a/.fastly/help/ecp-feature.mdx b/.fastly/help/ecp-feature.mdx new file mode 100644 index 000000000..358ff25e0 --- /dev/null +++ b/.fastly/help/ecp-feature.mdx @@ -0,0 +1,7 @@ +--- +id: ecp-feature +title: Compute is not enabled on your account +template: help +--- + +Our edge compute platform is in limited availability and not yet available to all customers. Contact [Fastly Support](https://support.fastly.com/hc/en-us) or your account manager to have the feature enabled on your account. diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 000000000..fc85bc118 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @fastly/developer-tools \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 000000000..e0b5dfb74 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: "[FEATURE REQUEST] ..." +labels: feature request +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. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**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 000000000..2f0943a0b --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,24 @@ + All Submissions: + +* [ ] Have you followed the guidelines in our Contributing document? +* [ ] Have you checked to ensure there aren't other open [Pull Requests](https://github.com/fastly/cli/pulls) for the same update/change? + + + +### New Feature Submissions: + +* [ ] Does your submission pass tests? + +### Changes to Core Features: + +* [ ] Have you added an explanation of what your changes do and why you'd like us to include them? +* [ ] Have you written new tests for your core changes, as applicable? +* [ ] Have you successfully run tests with your changes locally? + +### User Impact + +* [ ] What is the user impact of this change? + +### Are there any considerations that need to be addressed for release? + + \ No newline at end of file diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..7fbb62c5a --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,20 @@ +version: 2 +updates: + - package-ecosystem: "gomod" + directory: "/" + schedule: + interval: "weekly" + allow: + - dependency-type: "all" + groups: + go-dependencies: + patterns: + - "*" + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + groups: + gha-dependencies: + patterns: + - "*" diff --git a/.github/workflows/dependabot_changelog_update.yml b/.github/workflows/dependabot_changelog_update.yml new file mode 100644 index 000000000..9dbe28816 --- /dev/null +++ b/.github/workflows/dependabot_changelog_update.yml @@ -0,0 +1,35 @@ +name: Generate changelog entry for Dependabot + +on: + pull_request: + types: + - opened + - synchronize + - reopened +jobs: + dependabot-changelog-update: + if: github.actor == 'dependabot[bot]' + runs-on: ubuntu-latest + steps: + - name: Generate a GitHub token + id: github-token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ vars.GH_APP_ID }} + private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: "cli" + - name: Checkout code + uses: actions/checkout@v4 + with: + token: ${{ steps.github-token.outputs.token }} + - name: Generate changelog entry + uses: dangoslen/dependabot-changelog-helper@v4 + with: + activationLabels: dependencies + changelogPath: './CHANGELOG.md' + entryPrefix: 'build(deps): ' + - name: Commit changelog entry + uses: stefanzweifel/git-auto-commit-action@v5 + with: + commit_message: "docs(CHANGELOG.md): add dependency bump from dependabot" diff --git a/.github/workflows/merge_to_main.yml b/.github/workflows/merge_to_main.yml new file mode 100644 index 000000000..a2bb92a6d --- /dev/null +++ b/.github/workflows/merge_to_main.yml @@ -0,0 +1,38 @@ +name: Build CLI Binaries +on: + pull_request: + branches: + - "main" + types: + [closed] +jobs: + build: + if: ${{ github.event.pull_request.merged }} + strategy: + matrix: + platform: [ubuntu-latest, macos-latest] + runs-on: ${{ matrix.platform }} + steps: + - name: "Checkout code" + uses: actions/checkout@v4 + - name: "Install Node" + uses: actions/setup-node@v4 + with: + node-version: 18 + - name: "Install Rust" + uses: dtolnay/rust-toolchain@1.83.0 # to install tq via `make config` + - name: Install Go + uses: actions/setup-go@v5 + with: + go-version: 1.24.x + - name: "Install dependencies" + run: make mod-download + shell: bash + - name: "Create Build" + run: make build + shell: bash + - name: "Upload Build" + uses: actions/upload-artifact@v4 + with: + name: fastly-cli-build-${{ matrix.platform }}-${{ github.sha }} + path: fastly diff --git a/.github/workflows/pr_test.yml b/.github/workflows/pr_test.yml index 96c4d9774..2ad7deeb2 100644 --- a/.github/workflows/pr_test.yml +++ b/.github/workflows/pr_test.yml @@ -1,84 +1,124 @@ -on: pull_request +on: + pull_request: + types: + - opened + - synchronize + - reopened + branches: + - main name: Test +# Stop any in-flight CI jobs when a new commit is pushed. +concurrency: + group: ${{ github.ref_name }} + cancel-in-progress: true jobs: + changelog: + if: github.actor != 'dependabot[bot]' + runs-on: ubuntu-latest + steps: + - uses: dangoslen/changelog-enforcer@v3 + config: + runs-on: ubuntu-latest + steps: + - name: "Checkout code" + uses: actions/checkout@v4 + - name: "Install Rust" + uses: dtolnay/rust-toolchain@stable + - name: "Generate static app config" + run: make config + - name: "Config Artifact" + uses: actions/upload-artifact@v4 + with: + name: config-artifact-${{ github.sha }} + path: pkg/config/config.toml lint: + needs: [config] runs-on: ubuntu-latest steps: - - name: Checkout code - uses: actions/checkout@v1 - - name: Install Go - uses: actions/setup-go@v2 - with: - go-version: 1.16.x - - name: Restore cache - id: cache - uses: actions/cache@v1 - with: - path: ~/go/bin - key: ${{ runner.os }}-go-bin-${{ hashFiles('**/go.sum') }} - restore-keys: | - ${{ runner.os }}-go-bin- - - name: Restore cache - uses: actions/cache@v1 - with: - path: ~/go/pkg/mod - key: ${{ runner.os }}-go-mod-${{ hashFiles('**/go.sum') }} - restore-keys: | - ${{ runner.os }}-go-mod- - - name: Install dependencies - if: steps.cache.outputs.cache-hit != 'true' - run: make dependencies - shell: bash - - name: Mod Tidy - run: make tidy - - name: Fmt - run: make fmt - - name: Vet - run: make vet - shell: bash - - name: Staticcheck - run: make staticcheck - shell: bash - - name: Lint - run: make lint - shell: bash - - name: Gosec - run: make gosec - shell: bash + - name: "Checkout code" + uses: actions/checkout@v4 + - name: "Install Rust" + uses: dtolnay/rust-toolchain@stable + - name: Install Go + uses: actions/setup-go@v5 + with: + go-version: 1.24.x + - name: "Install dependencies" + run: make mod-download + shell: bash + - name: "Config Artifact" + uses: actions/download-artifact@v4 + with: + name: config-artifact-${{ github.sha }} + - name: "Move Config" + run: mv config.toml pkg/config/config.toml + - name: golangci-lint + uses: golangci/golangci-lint-action@v8 + with: + version: v2.1 + only-new-issues: true test: + needs: [config] strategy: matrix: - go-version: [1.16.x] - node-version: [12] - rust-toolchain: [1.49.0] + tinygo-version: [0.31.2] + go-version: [1.24.x] + node-version: [18] platform: [ubuntu-latest, macos-latest, windows-latest] runs-on: ${{ matrix.platform }} steps: - - name: Checkout code - uses: actions/checkout@v1 - - name: Install Go - uses: actions/setup-go@v2 - with: - go-version: ${{ matrix.go-version }} - - name: Restore cache - uses: actions/cache@v1 - with: - path: ~/go/pkg/mod - key: ${{ runner.os }}-go-mod-${{ hashFiles('**/go.sum') }} - restore-keys: | - ${{ runner.os }}-go-mod- - - name: Install Rust - uses: actions-rs/toolchain@v1 - with: - toolchain: ${{ matrix.rust-toolchain }} - - name: Add wasm32-wasi Rust target - run: rustup target add wasm32-wasi --toolchain ${{ matrix.rust-toolchain }} - - uses: actions/setup-node@v1 - with: - node-version: ${{ matrix.node-version }} - - name: Test - run: make test - shell: bash - env: - TEST_COMPUTE_INIT: true - TEST_COMPUTE_BUILD: true + - name: "Checkout code" + uses: actions/checkout@v4 + - name: "Install Go" + uses: actions/setup-go@v5 + with: + go-version: ${{ matrix.go-version }} + # IMPORTANT: Disable caching to prevent cache restore errors later. + cache: false + - uses: acifani/setup-tinygo@v2 + with: + tinygo-version: ${{ matrix.tinygo-version }} + - name: "Install Rust" + uses: dtolnay/rust-toolchain@stable + - name: "Add wasm32-wasip1 Rust target" + run: rustup target add wasm32-wasip1 --toolchain stable + - name: "Validate Rust toolchain" + run: rustup show && rustup target list --installed --toolchain stable + shell: bash + - name: "Install Node" + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + - name: "Config Artifact" + uses: actions/download-artifact@v4 + with: + name: config-artifact-${{ github.sha }} + - name: "Move Config" + run: mv config.toml pkg/config/config.toml + - name: "Modify git cloned repo files 'modified' times" + run: go run ./scripts/go-test-cache/main.go + # NOTE: Windows should fail quietly running pre-requisite target of `test`. + # + # On Windows, executing `make config` directly works fine. + # But when `config` is a pre-requisite to running `test`, it fails. + # But only when run via GitHub Actions. + # The ../../scripts/config.sh isn't run because you can't nest PowerShell instances. + # Each GitHub Action 'run' step is a PowerShell instance. + # And each instance is run as: powershell.exe -command ". '...'" + - name: "Test suite" + run: make test + shell: bash + env: + # NOTE: The following lets us focus the test run while debugging. + # TEST_ARGS: "-run TestBuild ./pkg/commands/compute/..." + TEST_COMPUTE_INIT: true + TEST_COMPUTE_BUILD: true + TEST_COMPUTE_DEPLOY: true + docker-builds: + runs-on: ubuntu-latest + steps: + - name: "Checkout code" + uses: actions/checkout@v4 + - name: Build docker images + run: | + for dockerFile in Dockerfile*; do docker build -f $dockerFile . ; done diff --git a/.github/workflows/publish_release.yml b/.github/workflows/publish_release.yml new file mode 100644 index 000000000..a7f598cf6 --- /dev/null +++ b/.github/workflows/publish_release.yml @@ -0,0 +1,50 @@ +name: NPM Release +on: + workflow_dispatch: + release: + types: + - published +jobs: + npm_release: + runs-on: ubuntu-latest + steps: + - name: "Checkout code" + uses: actions/checkout@v4 + - name: "Fetch unshallow repo" + run: git fetch --prune --unshallow + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 'lts/*' + registry-url: 'https://registry.npmjs.org' + - name: Set up auth for GitHub packages + run: | + npm config set "//npm.pkg.github.com/:_authToken" "\${NODE_AUTH_TOKEN}" + - name: Update npm packages to latest version + working-directory: ./npm/@fastly/cli + run: npm install && npm version "${{ github.ref_name }}" --allow-same-version + - name: Publish packages to npmjs.org + working-directory: ./npm/@fastly + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + for dir in *; do + ( + echo $dir + cd $dir + npm publish --access=public + ) + done + - name: Publish packages to GitHub packages + working-directory: ./npm/@fastly + env: + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + npm config set "@fastly:registry" "https://npm.pkg.github.com/" + for dir in *; do + ( + echo $dir + cd $dir + npm publish --access=public + ) + done diff --git a/.github/workflows/tag_release.yml b/.github/workflows/tag_release.yml deleted file mode 100644 index 22a8a44a7..000000000 --- a/.github/workflows/tag_release.yml +++ /dev/null @@ -1,38 +0,0 @@ -name: Release -on: - push: - tags: - - 'v*' -jobs: - goreleaser: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v2 - - name: Unshallow - run: git fetch --prune --unshallow - - name: Install Go - uses: actions/setup-go@v2 - with: - go-version: '1.16.x' - - name: Set GOVERSION - id: set_goversion - run: echo "GOVERSION=$(go version)" >> $GITHUB_ENV - - name: Install Ruby - uses: actions/setup-ruby@v1 - with: - ruby-version: '2.7' - - name: Install github_changelog_generator - run: gem install github_changelog_generator -v 1.15.0 - - name: Generate Release changelog - run: make release-changelog - env: - CHANGELOG_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Run GoReleaser - uses: goreleaser/goreleaser-action@v2 - with: - version: v0.155.2 # goreleaser version (NOT goreleaser-action version) - args: release --rm-dist --release-notes=RELEASE_CHANGELOG.md - env: - GOVERSION: ${{ env.GOVERSION }} - GITHUB_TOKEN: ${{ secrets.RELEASE_GITHUB_TOKEN }} diff --git a/.github/workflows/tag_to_draft_release.yml b/.github/workflows/tag_to_draft_release.yml new file mode 100644 index 000000000..e06aefc38 --- /dev/null +++ b/.github/workflows/tag_to_draft_release.yml @@ -0,0 +1,44 @@ +name: Draft Release from Tag +on: + workflow_dispatch: + push: + tags: + - 'v*' +jobs: + goreleaser: + runs-on: ubuntu-latest + steps: + - name: "Checkout code" + uses: actions/checkout@v4 + - name: "Fetch unshallow repo" + run: git fetch --prune --unshallow + - name: "Install Go" + uses: actions/setup-go@v5 + with: + go-version: '1.24.x' + - name: "Install Rust" + uses: dtolnay/rust-toolchain@stable + - name: "Generate static app config" + run: make config + # Passing the raw SSH private key causes an error: + # Load key "/tmp/id_*": invalid format + # + # Testing locally we discovered that storing in a file and passing the file path works. + # + # NOTE: + # The file aur_key must be added to .gitignore otherwise a 'dirty state' error is triggered in goreleaser. + # https://github.com/goreleaser/goreleaser/blob/9505cf7054b05a6e9a4a36f806d525bc33660e9e/www/docs/errors/dirty.md + # + # You must also reduce the permissions from a default of 0644 to 600 to avoid a 'bad permissions' error. + - name: "Store AUR_KEY in local file" + run: echo '${{ secrets.AUR_KEY }}' > '${{ github.workspace }}/aur_key' && chmod 600 '${{ github.workspace }}/aur_key' + - name: "Run GoReleaser" + uses: goreleaser/goreleaser-action@v6 + with: + # goreleaser version (NOT goreleaser-action version) + # update inline with the Makefile + version: '~> v2' + args: release --clean + env: + AUR_KEY: '${{ github.workspace }}/aur_key' + GITHUB_TOKEN: ${{ secrets.RELEASE_GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 5f27eb8b7..5370bf679 100644 --- a/.gitignore +++ b/.gitignore @@ -1,28 +1,33 @@ -fastly +# Fastly binary +**/fastly +# But allow fastly main package !cmd/fastly RELEASE_CHANGELOG.md # Fastly package format files **/fastly.toml -!pkg/compute/testdata/build/fastly.toml +!pkg/commands/compute/testdata/build/rust/fastly.toml **/Cargo.toml -!pkg/compute/testdata/build/Cargo.toml +!pkg/commands/compute/testdata/build/rust/Cargo.toml **/Cargo.lock -!pkg/compute/testdata/build/Cargo.lock +!pkg/commands/compute/testdata/build/rust/Cargo.lock **/*.tar.gz -!pkg/compute/testdata/deploy/pkg/package.tar.gz +!pkg/github/testdata/*.tar.gz +!pkg/commands/compute/testdata/deploy/pkg/package.tar.gz **/bin **/src -!pkg/compute/testdata/build/src +!pkg/commands/compute/testdata/build/rust/src +!pkg/commands/compute/testdata/build/javascript/src **/target rust-toolchain .cargo **/node_modules +pkg/commands/compute/package-lock.json # Binaries for programs and plugins *.exe -*.exe~ +*.exe~* *.dll *.so *.dylib @@ -50,4 +55,21 @@ rust-toolchain # Ignore binaries dist/ build/ -!pkg/compute/testdata/build/ +!pkg/commands/compute/testdata/build/ + +# Ignore application configuration +vendor/ + +# Ignore generated file for AUR_KEY which is passed to goreleaser as an environment variable. +aur_key + +# Ignore static config that is embedded into the CLI +# All Makefile targets use the 'config' as a prerequisite (which generates the config) +pkg/config/config.toml + +# Ignore commitlint tool +commitlint.config.js +callvis.svg + +# Ignore generated npm packages +npm/@fastly/cli-*/ diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 000000000..d690b3ece --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,57 @@ +version: "2" +run: + allow-parallel-runners: true + modules-download-mode: readonly +linters: + enable: + - durationcheck + - errcheck + - exhaustive + - forcetypeassert + - gocritic + - godot + - gosec + - govet + - ineffassign + - makezero + - misspell + - nilerr + - predeclared + - revive + - staticcheck + - unconvert + - unparam + - unused + settings: + govet: + enable: + - nilness + staticcheck: + checks: + - all + - '-QF1008' + exclusions: + generated: lax + presets: + - comments + - common-false-positives + - legacy + - std-error-handling + paths: + - third_party$ + - builtin$ + - examples$ +formatters: + enable: + - gofumpt + - goimports + settings: + goimports: + local-prefixes: + - github.com/fastly + exclusions: + generated: lax + paths: + - third_party$ + - builtin$ + - examples$ \ No newline at end of file diff --git a/.goreleaser.yml b/.goreleaser.yml index 859e245fe..e70c0a041 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -1,31 +1,48 @@ +# https://goreleaser.com/customization/project/ project_name: fastly +version: 2 + +# https://goreleaser.com/customization/release/ release: + draft: true prerelease: auto extra_files: - glob: "dist/usage.json" + +# https://goreleaser.com/customization/hooks/ before: hooks: - go mod tidy - go mod download + +# https://goreleaser.com/customization/builds/ builds: - <<: &build_defaults main: ./cmd/fastly ldflags: - - -s -w -X "github.com/fastly/cli/pkg/revision.AppVersion={{ .Version }}" + - -s -w -X "github.com/fastly/cli/pkg/revision.AppVersion=v{{ .Version }}" - -X "github.com/fastly/cli/pkg/revision.GitCommit={{ .ShortCommit }}" - - -X "github.com/fastly/cli/pkg/revision.GoVersion={{ .Env.GOVERSION }}" + - -X "github.com/fastly/cli/pkg/revision.Environment=release" + env: + - CGO_ENABLED=0 id: macos goos: [darwin] - goarch: [amd64] + goarch: [amd64, arm64] - <<: *build_defaults + env: + - CGO_ENABLED=0 id: linux goos: [linux] - goarch: [386, amd64, arm64] + goarch: ["386", amd64, arm64] - <<: *build_defaults + env: + - CGO_ENABLED=0 id: windows goos: [windows] - goarch: [386, amd64] + goarch: ["386", amd64, arm64] - <<: *build_defaults + env: + - CGO_ENABLED=0 id: generate-usage goos: [linux] goarch: [amd64] @@ -34,6 +51,8 @@ builds: hooks: post: - cmd: "scripts/documentation.sh {{ .Path }}" + +# https://goreleaser.com/customization/archive/ archives: - id: nix builds: [macos, linux] @@ -43,21 +62,65 @@ archives: - none* wrap_in_directory: false format: tar.gz - - id: windows + - id: windows-tar + builds: [windows] + <<: *archive_defaults + wrap_in_directory: false + format: tar.gz + - id: windows-zip builds: [windows] <<: *archive_defaults wrap_in_directory: false format: zip +# https://goreleaser.com/customization/aur/ +aurs: + - homepage: "https://github.com/fastly/cli" + description: "A CLI for interacting with the Fastly platform" + maintainers: + - 'oss@fastly.com' + license: "Apache license 2.0" + skip_upload: auto + provides: + - fastly + conflicts: + - fastly + + # The SSH private key that should be used to commit to the Git repository. + # This can either be a path or the key contents. + # + # WARNING: do not expose your private key in the config file! + private_key: '{{ .Env.AUR_KEY }}' + + # The AUR Git URL for this package. + # Defaults to empty. + git_url: 'ssh://aur@aur.archlinux.org/fastly-bin.git' + + # List of packages that are not needed for the software to function, + # but provide additional features. + # + # Must be in the format `package: short description of the extra functionality`. + # + # Defaults to empty. + optdepends: + - 'viceroy: for running service locally' + + # The value to be passed to `GIT_SSH_COMMAND`. + # + # + # Defaults to `ssh -i {{ .KeyPath }} -o StrictHostKeyChecking=accept-new -F /dev/null`. + git_ssh_command: 'ssh -i {{ .KeyPath }} -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -F /dev/null' + +# https://goreleaser.com/customization/homebrew/ brews: - name: fastly ids: [nix] - tap: + repository: owner: fastly name: homebrew-tap skip_upload: auto - description: Fastly CLI + description: A CLI for interacting with the Fastly platform homepage: https://github.com/fastly/cli - folder: Formula + directory: Formula custom_block: | head do url "https://github.com/fastly/cli.git" @@ -71,25 +134,52 @@ brews: test: | help_text = shell_output("#{bin}/fastly --help") assert_includes help_text, "Usage:" + +# https://goreleaser.com/customization/nfpm/ nfpms: - license: Apache 2.0 maintainer: Fastly homepage: https://github.com/fastly/cli bindir: /usr/local/bin + description: CLI tool for interacting with the Fastly API. formats: - deb - rpm -scoop: - bucket: - owner: fastly - name: scoop-cli - homepage: https://github.com/fastly/cli - skip_upload: auto - description: Fastly CLI - license: Apache 2.0 + contents: + - src: deb-copyright + dst: /usr/share/doc/fastly/copyright + packager: deb + +# https://goreleaser.com/customization/checksum/ checksum: name_template: "{{ .ProjectName }}_v{{ .Version }}_SHA256SUMS" + +# https://goreleaser.com/customization/snapshots/ snapshot: - name_template: "{{ .Tag }}-next" + version_template: "{{ .Tag }}-next" + +# https://goreleaser.com/customization/changelog/ changelog: - sort: asc + disable: true + +# https://goreleaser.com/customization/docker/ +# dockers: +# - <<: &build_opts +# use: buildx +# goos: linux +# goarch: amd64 +# image_templates: +# - "ghcr.io/fastly/cli:{{ .Version }}" +# build_flag_templates: +# - "--platform=linux/amd64" +# - --label=title={{ .ProjectName }} +# - --label=description={{ .ProjectName }} +# - --label=url=https://github.com/fastly/cli +# - --label=source=https://github.com/fastly/cli +# - --label=version={{ .Version }} +# - --label=created={{ time "2006-01-02T15:04:05Z07:00" }} +# - --label=revision={{ .FullCommit }} +# - --label=licenses=Apache-2.0 +# dockerfile: Dockerfile-node +# - <<: *build_opts +# dockerfile: Dockerfile-rust diff --git a/.tmpl/create.go b/.tmpl/create.go new file mode 100644 index 000000000..ea2e314da --- /dev/null +++ b/.tmpl/create.go @@ -0,0 +1,110 @@ +package ${CLI_PACKAGE} + +import ( + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/config" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/text" + "github.com/fastly/go-fastly/v4/fastly" +) + +// NewCreateCommand returns a usable command registered under the parent. +func NewCreateCommand(parent argparser.Registerer, globals *config.Data, data manifest.Data) *CreateCommand { + var c CreateCommand + c.CmdClause = parent.Command("create", "<...>").Alias("add") + c.Globals = globals + c.manifest = data + + // Required flags + // c.CmdClause.Flag("<...>", "<...>").Required().StringVar(&c.<...>) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional flags + // c.CmdClause.Flag("<...>", "<...>").Action(c.<...>.Set).StringVar(&c.<...>.Value) + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.autoClone.Set, + Dst: &c.autoClone.Value, + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &c.manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + + return &c +} + +// CreateCommand calls the Fastly API to create an appropriate resource. +type CreateCommand struct { + argparser.Base + + autoClone argparser.OptionalAutoClone + manifest manifest.Data + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion +} + +// Exec invokes the application logic for the command. +func (c *CreateCommand) Exec(in io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.autoClone, + Client: c.Globals.Client, + Manifest: c.manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flag.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]interface{}{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + input := c.constructInput(serviceID, serviceVersion.Number) + + r, err := c.Globals.Client.Create${CLI_API}(input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]interface{}{ + "Service ID": serviceID, + "Service Version": serviceVersion.Number, + }) + return err + } + + text.Success(out, "Created <...> '%s' (service: %s, version: %d)", r.<...>, r.ServiceID, r.ServiceVersion) + return nil +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *CreateCommand) constructInput(serviceID string, serviceVersion int) *fastly.Create${CLI_API}Input { + var input fastly.Create${CLI_API}Input + + input.ServiceID = serviceID + input.ServiceVersion = serviceVersion + + // if c.<...>.WasSet { + // input.<...> = c.<...>.Value + // } + + return &input +} diff --git a/.tmpl/delete.go b/.tmpl/delete.go new file mode 100644 index 000000000..b51fe7a2b --- /dev/null +++ b/.tmpl/delete.go @@ -0,0 +1,107 @@ +package ${CLI_PACKAGE} + +import ( + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/config" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/text" + "github.com/fastly/go-fastly/v4/fastly" +) + +// NewDeleteCommand returns a usable command registered under the parent. +func NewDeleteCommand(parent argparser.Registerer, globals *config.Data, data manifest.Data) *DeleteCommand { + var c DeleteCommand + c.CmdClause = parent.Command("delete", "<...>").Alias("remove") + c.Globals = globals + c.manifest = data + + // Required flags + // c.CmdClause.Flag("<...>", "<...>").Required().StringVar(&c.<...>) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional flags + // c.CmdClause.Flag("<...>", "<...>").Action(c.<...>.Set).StringVar(&c.<...>.Value) + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.autoClone.Set, + Dst: &c.autoClone.Value, + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &c.manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + + return &c +} + +// DeleteCommand calls the Fastly API to delete an appropriate resource. +type DeleteCommand struct { + argparser.Base + + autoClone argparser.OptionalAutoClone + manifest manifest.Data + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion +} + +// Exec invokes the application logic for the command. +func (c *DeleteCommand) Exec(in io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.autoClone, + Client: c.Globals.Client, + Manifest: c.manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flag.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]interface{}{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + input := c.constructInput(serviceID, serviceVersion.Number) + + err := c.Globals.Client.Delete${CLI_API}(input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]interface{}{ + "Service ID": serviceID, + "Service Version": serviceVersion.Number, + }) + return err + } + + text.Success(out, "Deleted <...> '%s' (service: %s, version: %d)", c.<...>, serviceID, serviceVersion.Number) + return nil +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *DeleteCommand) constructInput(serviceID string, serviceVersion int) *fastly.Delete${CLI_API}Input { + var input fastly.Delete${CLI_API}Input + + input.ACLID = c.aclID + input.ID = c.id + input.ServiceID = serviceID + + return &input +} diff --git a/.tmpl/describe.go b/.tmpl/describe.go new file mode 100644 index 000000000..f99a1ec05 --- /dev/null +++ b/.tmpl/describe.go @@ -0,0 +1,116 @@ +package ${CLI_PACKAGE} + +import ( + "fmt" + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/config" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/go-fastly/v4/fastly" +) + +// NewDescribeCommand returns a usable command registered under the parent. +func NewDescribeCommand(parent argparser.Registerer, globals *config.Data, data manifest.Data) *DescribeCommand { + var c DescribeCommand + c.CmdClause = parent.Command("describe", "<...>").Alias("get") + c.Globals = globals + c.manifest = data + + // Required flags + // c.CmdClause.Flag("<...>", "<...>").Required().StringVar(&c.<...>) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional flags + // c.CmdClause.Flag("<...>", "<...>").Action(c.<...>.Set).StringVar(&c.<...>.Value) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &c.manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + + return &c +} + +// DescribeCommand calls the Fastly API to describe an appropriate resource. +type DescribeCommand struct { + argparser.Base + + manifest manifest.Data + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion +} + +// Exec invokes the application logic for the command. +func (c *DescribeCommand) Exec(in io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Client: c.Globals.Client, + Manifest: c.manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flag.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]interface{}{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + input := c.constructInput(serviceID, serviceVersion.Number) + + r, err := c.Globals.Client.Get${CLI_API}(input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]interface{}{ + "Service ID": serviceID, + "Service Version": serviceVersion.Number, + }) + return err + } + + c.print(out, r) + return nil +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *DescribeCommand) constructInput(serviceID string, serviceVersion int) *fastly.Get${CLI_API}Input { + var input fastly.Get${CLI_API}Input + + input.ACLID = c.aclID + input.ID = c.id + input.ServiceID = serviceID + + return &input +} + +// print displays the information returned from the API. +func (c *DescribeCommand) print(out io.Writer, r *fastly.${CLI_API}) { + fmt.Fprintf(out, "\nService ID: %s\n", r.ServiceID) + fmt.Fprintf(out, "Service Version: %d\n\n", r.ServiceVersion) + fmt.Fprintf(out, "<...>: %s\n\n", r.<...>) + + if r.CreatedAt != nil { + fmt.Fprintf(out, "Created at: %s\n", r.CreatedAt) + } + if r.UpdatedAt != nil { + fmt.Fprintf(out, "Updated at: %s\n", r.UpdatedAt) + } + if r.DeletedAt != nil { + fmt.Fprintf(out, "Deleted at: %s\n", r.DeletedAt) + } +} diff --git a/.tmpl/doc.go b/.tmpl/doc.go new file mode 100644 index 000000000..d97f70a6d --- /dev/null +++ b/.tmpl/doc.go @@ -0,0 +1,2 @@ +// Package ${CLI_PACKAGE} contains commands to <...>. +package ${CLI_PACKAGE} diff --git a/.tmpl/doc_parent.go b/.tmpl/doc_parent.go new file mode 100644 index 000000000..a5d6c6e00 --- /dev/null +++ b/.tmpl/doc_parent.go @@ -0,0 +1,2 @@ +// Package ${CLI_CATEGORY} contains commands for <...>. +package ${CLI_CATEGORY} diff --git a/.tmpl/list.go b/.tmpl/list.go new file mode 100644 index 000000000..fbc2178eb --- /dev/null +++ b/.tmpl/list.go @@ -0,0 +1,135 @@ +package ${CLI_PACKAGE} + +import ( + "fmt" + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/config" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/text" + "github.com/fastly/go-fastly/v4/fastly" +) + +// NewListCommand returns a usable command registered under the parent. +func NewListCommand(parent argparser.Registerer, globals *config.Data, data manifest.Data) *ListCommand { + var c ListCommand + c.CmdClause = parent.Command("list", "<...>") + c.Globals = globals + c.manifest = data + + // Required flags + // c.CmdClause.Flag("<...>", "<...>").Required().StringVar(&c.<...>) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional Flags + // c.CmdClause.Flag("<...>", "<...>").Action(c.<...>.Set).StringVar(&c.<...>.Value) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &c.manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + + return &c +} + +// ListCommand calls the Fastly API to list appropriate resources. +type ListCommand struct { + argparser.Base + + manifest manifest.Data + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion +} + +// Exec invokes the application logic for the command. +func (c *ListCommand) Exec(in io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Client: c.Globals.Client, + Manifest: c.manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flag.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]interface{}{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + input := c.constructInput(serviceID, serviceVersion.Number) + + rs, err := c.Globals.Client.List${CLI_API}s(input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]interface{}{ + "Service ID": serviceID, + "Service Version": serviceVersion.Number, + }) + return err + } + + if c.Globals.Verbose() { + c.printVerbose(out, serviceID, rs) + } else { + c.printSummary(out, rs) + } + return nil +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *ListCommand) constructInput(serviceID string) *fastly.List${CLI_API}sInput { + var input fastly.List${CLI_API}sInput + + input.ServiceID = serviceID + input.ServiceVersion = serviceVersion + + return &input +} + +// printVerbose displays the information returned from the API in a verbose +// format. +func (c *ListCommand) printVerbose(out io.Writer, serviceID string, serviceVersion int, rs []*fastly.${CLI_API}) { + fmt.Fprintf(out, "\nService ID: %s\n", serviceID) + fmt.Fprintf(out, "Service Version: %d\n", serviceVersion) + + for _, r := range rs { + fmt.Fprintf(out, "\n<...>: %s\n\n", r.<...>) + + if r.CreatedAt != nil { + fmt.Fprintf(out, "Created at: %s\n", r.CreatedAt) + } + if r.UpdatedAt != nil { + fmt.Fprintf(out, "Updated at: %s\n", r.UpdatedAt) + } + if r.DeletedAt != nil { + fmt.Fprintf(out, "Deleted at: %s\n", r.DeletedAt) + } + } +} + +// printSummary displays the information returned from the API in a summarised +// format. +func (c *ListCommand) printSummary(out io.Writer, rs []*fastly.${CLI_API}) { + t := text.NewTable(out) + t.AddHeader("SERVICE ID", "<...>") + for _, r := range rs { + t.AddLine(r.ServiceID, r.<...>) + } + t.Print() +} diff --git a/.tmpl/root.go b/.tmpl/root.go new file mode 100644 index 000000000..de87fbe0d --- /dev/null +++ b/.tmpl/root.go @@ -0,0 +1,28 @@ +package ${CLI_PACKAGE} + +import ( + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/config" +) + +// RootCommand is the parent command for all subcommands in this package. +// It should be installed under the primary root command. +type RootCommand struct { + argparser.Base + // no flags +} + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent argparser.Registerer, globals *config.Data) *RootCommand { + var c RootCommand + c.Globals = globals + c.CmdClause = parent.Command("${CLI_COMMAND}", "<...>") + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(in io.Reader, out io.Writer) error { + panic("unreachable") +} diff --git a/.tmpl/root_parent.go b/.tmpl/root_parent.go new file mode 100644 index 000000000..85f1dd884 --- /dev/null +++ b/.tmpl/root_parent.go @@ -0,0 +1,28 @@ +package ${CLI_CATEGORY} + +import ( + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/config" +) + +// RootCommand is the parent command for all subcommands in this package. +// It should be installed under the primary root command. +type RootCommand struct { + argparser.Base + // no flags +} + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent argparser.Registerer, globals *config.Data) *RootCommand { + var c RootCommand + c.Globals = globals + c.CmdClause = parent.Command("${CLI_CATEGORY_COMMAND}", "<...>") + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(in io.Reader, out io.Writer) error { + panic("unreachable") +} diff --git a/.tmpl/test.go b/.tmpl/test.go new file mode 100644 index 000000000..9227c2929 --- /dev/null +++ b/.tmpl/test.go @@ -0,0 +1,329 @@ +package ${CLI_PACKAGE}_test + +import ( + "testing" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" +) + +const ( + baseCommand = "${CLI_COMMAND}" +) + +func TestCreate(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --version flag", + WantError: "error parsing arguments: required flag --version not provided", + }, + { + Name: "validate missing --service-id flag", + Args: "--version 3", + WantError: "error reading service: no service ID found", + }, + { + Name: "validate missing --autoclone flag with 'active' service", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + }, + Args: "--service-id 123 --version 1", + WantError: "service version 1 is active", + }, + { + Name: "validate missing --autoclone flag with 'locked' service", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + }, + Args: "--service-id 123 --version 2", + WantError: "service version 2 is locked", + }, + { + Name: "validate Create${CLI_API} API error", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + Create${CLI_API}Fn: func(i *fastly.Create${CLI_API}Input) (*fastly.${CLI_API}, error) { + return nil, testutil.Err + }, + }, + Args: "--service-id 123 --version 3", + WantError: testutil.Err.Error(), + }, + { + Name: "validate Create${CLI_API} API success", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + Create${CLI_API}Fn: func(i *fastly.Create${CLI_API}Input) (*fastly.${CLI_API}, error) { + return &fastly.${CLI_API}{ + ServiceID: i.ServiceID, + }, nil + }, + }, + Args: "--service-id 123 --version 3", + WantOutput: "Created <...> '456' (service: 123)", + }, + { + Name: "validate --autoclone results in cloned service version", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + Create${CLI_API}Fn: func(i *fastly.Create${CLI_API}Input) (*fastly.${CLI_API}, error) { + return &fastly.VCL{ + ServiceID: i.ServiceID, + ServiceVersion: i.ServiceVersion, + }, nil + }, + }, + Args: "--autoclone --service-id 123 --version 1", + WantOutput: "Created <...> 'foo' (service: 123, version: 4)", + }, + } + + testutil.RunCLIScenarios(t, []string{baseCommand, "create"}, scenarios) +} + +func TestDelete(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --version flag", + WantError: "error parsing arguments: required flag --version not provided", + }, + { + Name: "validate missing --service-id flag", + Args: "--version 1", + WantError: "error reading service: no service ID found", + }, + { + Name: "validate missing --autoclone flag with 'active' service", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + }, + Args: "--service-id 123 --version 1", + WantError: "service version 1 is active", + }, + { + Name: "validate missing --autoclone flag with 'locked' service", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + }, + Args: "--service-id 123 --version 2", + WantError: "service version 2 is locked", + }, + { + Name: "validate Delete${CLI_API} API error", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + Delete${CLI_API}Fn: func(i *fastly.Delete${CLI_API}Input) error { + return testutil.Err + }, + }, + Args: "--service-id 123 --version 3", + WantError: testutil.Err.Error(), + }, + { + Name: "validate Delete${CLI_API} API success", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + Delete${CLI_API}Fn: func(i *fastly.Delete${CLI_API}Input) error { + return nil + }, + }, + Args: "--service-id 123 --version 3", + WantOutput: "Deleted <...> '456' (service: 123)", + }, + { + Name: "validate --autoclone results in cloned service version", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + Delete${CLI_API}Fn: func(i *fastly.Delete${CLI_API}Input) error { + return nil + }, + }, + Args: "--autoclone --service-id 123 --version 1", + WantOutput: "Deleted <...> 'foo' (service: 123, version: 4)", + }, + } + + testutil.RunCLIScenarios(t, []string{baseCommand, "delete"}, scenarios) +} + +func TestDescribe(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --version flag", + WantError: "error parsing arguments: required flag --version not provided", + }, + { + Name: "validate missing --service-id flag", + Args: "--version 3", + WantError: "error reading service: no service ID found", + }, + { + Name: "validate Get${CLI_API} API error", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + Get${CLI_API}Fn: func(i *fastly.Get${CLI_API}Input) (*fastly.${CLI_API}, error) { + return nil, testutil.Err + }, + }, + Args: "--service-id 123 --version 3", + WantError: testutil.Err.Error(), + }, + { + Name: "validate Get${CLI_API} API success", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + Get${CLI_API}Fn: get${CLI_API}, + }, + Args: "--service-id 123 --version 3", + WantOutput: "<...>", + }, + } + + testutil.RunCLIScenarios(t, []string{baseCommand, "describe"}, scenarios) +} + +func TestList(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --version flag", + WantError: "error parsing arguments: required flag --version not provided", + }, + { + Name: "validate missing --service-id flag", + Args: "--version 3", + WantError: "error reading service: no service ID found", + }, + { + Name: "validate List${CLI_API}s API error", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + List${CLI_API}sFn: func(i *fastly.List${CLI_API}sInput) ([]*fastly.${CLI_API}, error) { + return nil, testutil.Err + }, + }, + Args: "--service-id 123 --version 3", + WantError: testutil.Err.Error(), + }, + { + Name: "validate List${CLI_API}s API success", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + List${CLI_API}sFn: list${CLI_API}s, + }, + Args: "--service-id 123 --version 3", + WantOutput: "<...>", + }, + { + Name: "validate --verbose flag", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + List${CLI_API}sFn: list${CLI_API}s, + }, + Args: "--service-id 123 --version 3 --verbose", + WantOutput: "<...>", + }, + } + + testutil.RunCLIScenarios(t, []string{baseCommand, "list"}, scenarios) +} + +func TestUpdate(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --name flag", + Args: "--version 3", + WantError: "error parsing arguments: required flag --name not provided", + }, + { + Name: "validate missing --version flag", + Args: "--name foobar", + WantError: "error parsing arguments: required flag --version not provided", + }, + { + Name: "validate missing --service-id flag", + Args: "--name foobar --version 3", + WantError: "error reading service: no service ID found", + }, + { + Name: "validate missing --autoclone flag with 'active' service", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + }, + Args: "--name foobar --service-id 123 --version 1", + WantError: "service version 1 is active", + }, + { + Name: "validate missing --autoclone flag with 'locked' service", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + }, + Args: "--name foobar --service-id 123 --version 2", + WantError: "service version 2 is locked", + }, + { + Name: "validate Update${CLI_API} API error", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + Update${CLI_API}Fn: func(i *fastly.Update${CLI_API}Input) (*fastly.${CLI_API}, error) { + return nil, testutil.Err + }, + }, + Args: "--name foobar --service-id 123 --version 3", + WantError: testutil.Err.Error(), + }, + { + Name: "validate Update${CLI_API} API success with --new-name", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + Update${CLI_API}Fn: func(i *fastly.Update${CLI_API}Input) (*fastly.${CLI_API}, error) { + return &fastly.${CLI_API}{ + Name: *i.NewName, + ServiceID: i.ServiceID, + ServiceVersion: i.ServiceVersion, + }, nil + }, + }, + Args: "--name foobar --new-name beepboop --service-id 123 --version 3", + WantOutput: "Updated <...> 'beepboop' (previously: 'foobar', service: 123, version: 3)", + }, + } + + testutil.RunCLIScenarios(t, []string{baseCommand, "update"}, scenarios) +} + +func get${CLI_API}(i *fastly.Get${CLI_API}Input) (*fastly.${CLI_API}, error) { + t := testutil.Date + + return &fastly.${CLI_API}{ + ServiceID: i.ServiceID, + + CreatedAt: &t, + DeletedAt: &t, + UpdatedAt: &t, + }, nil +} + +func list${CLI_API}s(i *fastly.List${CLI_API}sInput) ([]*fastly.${CLI_API}, error) { + t := testutil.Date + vs := []*fastly.${CLI_API}{ + { + ServiceID: i.ServiceID, + + CreatedAt: &t, + DeletedAt: &t, + UpdatedAt: &t, + }, + { + ServiceID: i.ServiceID, + + CreatedAt: &t, + DeletedAt: &t, + UpdatedAt: &t, + }, + } + return vs, nil +} diff --git a/.tmpl/update.go b/.tmpl/update.go new file mode 100644 index 000000000..9ef517ba1 --- /dev/null +++ b/.tmpl/update.go @@ -0,0 +1,126 @@ +package ${CLI_PACKAGE} + +import ( + "encoding/json" + "fmt" + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/config" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/text" + "github.com/fastly/go-fastly/v4/fastly" +) + +// NewUpdateCommand returns a usable command registered under the parent. +func NewUpdateCommand(parent argparser.Registerer, globals *config.Data, data manifest.Data) *UpdateCommand { + var c UpdateCommand + c.CmdClause = parent.Command("update", "<...>") + c.Globals = globals + c.manifest = data + + // Required flags + // c.CmdClause.Flag("name", "<...>").Required().StringVar(&c.name) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional flags + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.autoClone.Set, + Dst: &c.autoClone.Value, + }) + c.CmdClause.Flag("new-name", "<...>").Action(c.newName.Set).StringVar(&c.newName.Value) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &c.manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + + return &c +} + +// UpdateCommand calls the Fastly API to update an appropriate resource. +type UpdateCommand struct { + argparser.Base + + autoClone argparser.OptionalAutoClone + manifest manifest.Data + name string + newName argparser.OptionalString + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion +} + +// Exec invokes the application logic for the command. +func (c *UpdateCommand) Exec(in io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.autoClone, + Client: c.Globals.Client, + Manifest: c.manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flag.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]interface{}{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + input, err := c.constructInput(serviceID, serviceVersion.Number) + if err != nil { + return err + } + + r, err := c.Globals.Client.Update${CLI_API}(input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]interface{}{ + "Service ID": serviceID, + "Service Version": serviceVersion.Number, + }) + return err + } + + if input.NewName != nil && *input.NewName != "" { + text.Success(out, "Updated <...> '%s' (previously: '%s', service: %s, version: %d)", r.Name, input.Name, r.ServiceID, r.ServiceVersion) + } else { + text.Success(out, "Updated <...> '%s' (service: %s, version: %d)", r.Name, r.ServiceID, r.ServiceVersion) + } + return nil +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *UpdateCommand) constructInput(serviceID string, serviceVersion int) (*fastly.Update${CLI_API}Input, error) { + var input fastly.Update${CLI_API}Input + + input.Name = c.name + input.ServiceID = serviceID + input.ServiceVersion = serviceVersion + + if !c.newName.WasSet && !c.content.WasSet { + return nil, fmt.Errorf("error parsing arguments: must provide either --new-name or --content to update the <...>") + } + if c.newName.WasSet { + input.NewName = fastly.String(c.newName.Value) + } + + return &input, nil + +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a89ba03d..4a127cd88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,2005 @@ -# Changelog +# CHANGELOG + +## [Unreleased] + +### Breaking: + +### Enhancements: + +### Bug fixes: + +### Dependencies: +- build(deps): `github.com/fastly/go-fastly/v10` from 10.0.1 to 10.1.0 ([#1467](https://github.com/fastly/cli/pull/1476)) +- build(deps): `github.com/fastly/go-fastly/v10` from 10.0.0 to 10.0.1 ([#1467](https://github.com/fastly/cli/pull/1467)) +- build(deps): `golang.org/x/net` from 0.37.0 to 0.39.0 ([#1467](https://github.com/fastly/cli/pull/1467)) +- build(go.mod): upgrade to go 1.24.0 in order to take advantage of the new tooling mechanism ([#1469](https://github.com/fastly/cli/pull/1469)) +- build(deps): `golang.org/x/image` from 0.15.0 to 0.18.0 ([#1470](https://github.com/fastly/cli/pull/1470)) +- build(deps): `github.com/magiconair/properties` from 1.8.7 to 1.8.10 ([#1474](https://github.com/fastly/cli/pull/1474)) +- build(deps): `golang.org/x/sys` from 0.32.0 to 0.33.0 ([#1472](https://github.com/fastly/cli/pull/1472)) +- build(deps): `cel.dev/expr` from 0.22.1 to 0.23.1 ([#1472](https://github.com/fastly/cli/pull/1472)) +- build(deps): `cloud.google.com/go` from 0.120.0 to 0.121.0 ([#1472](https://github.com/fastly/cli/pull/1472)) +- build(deps): `cloud.google.com/go/ai` from 0.8.0 to 0.11.0 ([#1472](https://github.com/fastly/cli/pull/1472)) +- build(deps): `cloud.google.com/go/auth` from 0.15.0 to 0.16.0 ([#1472](https://github.com/fastly/cli/pull/1472)) +- build(deps): `cloud.google.com/go/iam` from 1.4.2 to 1.5.0 ([#1472](https://github.com/fastly/cli/pull/1472)) +- build(deps): `cloud.google.com/go/kms` from 1.21.1 to 1.21.2 ([#1472](https://github.com/fastly/cli/pull/1472)) +- build(deps): `cloud.google.com/go/longrunning` from 0.6.6 to 0.6.7 ([#1472](https://github.com/fastly/cli/pull/1472)) +- build(deps): `cloud.google.com/go/monitoring` from 1.24.1 to 1.24.2 ([#1472](https://github.com/fastly/cli/pull/1472)) +- build(deps): `cloud.google.com/go/storage` from 1.51.0 to 1.52.0 ([#1472](https://github.com/fastly/cli/pull/1472)) +- build(deps): `github.com/42wim/httpsig` from 1.2.2 to 1.2.3 ([#1472](https://github.com/fastly/cli/pull/1472)) +- build(deps): `github.com/Azure/azure-sdk-for-go/sdk/azcore` from 1.17.1 to 1.18.0 ([#1472](https://github.com/fastly/cli/pull/1472)) +- build(deps): `github.com/Azure/azure-sdk-for-go/sdk/azidentity` from 1.8.2 to 1.9.0 ([#1472](https://github.com/fastly/cli/pull/1472)) +- build(deps): `github.com/fastly/go-fastly/v10` from 10.1.0 to 10.2.0 ([#1481](https://github.com/fastly/cli/pull/1481)) + +## [v11.2.0](https://github.com/fastly/cli/releases/tag/v11.2.0) (2025-04-10) + +### Enhancements: + +- feat(config): Support file/format for kv_store and secret_store in fastly.toml +- feat(config): Support metadata for kv_store in fastly.toml +- feat(logging): add support for passing a filepath as an argument for format in logging commands + +### Bug fixes: + +- fix(language/rust): Check for wasm32-wasi output from build process and inform user how to reconfigure their project. [#1458](https://github.com/fastly/cli/pull/1458) + +### Dependencies: +- dep(go.mod): upgrade go-fastly from v9 to v10 [#1448](https://github.com/fastly/cli/pull/1448) +- build(deps): `golang.org/x/oauth2` from 0.28.0 to 0.29.0 ([#1451](https://github.com/fastly/cli/pull/1451)) +- build(deps): `golang.org/x/sys` from 0.31.0 to 0.32.0 ([#1454](https://github.com/fastly/cli/pull/1454)) +- build(deps): `github.com/fsnotify/fsnotify` from 1.8.0 to 1.9.0 ([#1450](https://github.com/fastly/cli/pull/1450)) +- build(deps): `golang.org/x/term` from 0.30.0 to 0.31.0 ([#1455](https://github.com/fastly/cli/pull/1455)) +- build(deps): `golang.org/x/crypto` from 0.36.0 to 0.37.0 ([#1453](https://github.com/fastly/cli/pull/1453)) +- build(deps): `github.com/coreos/go-oidc/v3` from 3.13.0 to 3.14.1 ([#1456](https://github.com/fastly/cli/pull/1456)) +- build(deps): `actions/create-github-app-token` from 1 to 2 ([#1449](https://github.com/fastly/cli/pull/1449)) + +## [v11.1.0](https://github.com/fastly/cli/releases/tag/v11.1.0) (2025-03-27) + +### Bug fixes: + +- fix(logging): revert removal of placement param [#1444](https://github.com/fastly/cli/pull/1444) + +## [v11.0.0](https://github.com/fastly/cli/releases/tag/v11.0.0) (2025-03-25) + +### Breaking: + +- breaking(logging): The 'placement' parameter to the logging commands + has been removed; it was only used in combination with the Fastly + WAF, which is no longer supported. + [#1419](https://github.com/fastly/cli/pull/1419) +- breaking(language.rust): Switch Rust builds to wasm32-wasip1 instead of wasm32-wasi [#1382](https://github.com/fastly/cli/pull/1382) +- breaking(language.assemblyscript): Remove support for AssemblyScript [#1001](https://github.com/fastly/cli/pull/1001) +- breaking(compute/pack): use package name from manifest [#1025](https://github.com/fastly/cli/pull/1025) + +### Enhancements: +- fix(compute/init): Updates for renamed TypeScript default starter kit [#1405](https://github.com/fastly/cli/pull/1405) +- feat(objectstorage/accesskeys): add support for access keys [#1428](https://github.com/fastly/cli/pull/1428) + +### Dependencies +- build(deps): upgrade Go from 1.22 to 1.23 ([#624](https://github.com/fastly/cli/pull/1414)) +- build(deps): `github.com/rogpeppe/go-internal` from 1.13.1 to 1.14.1 ([#1416](https://github.com/fastly/cli/pull/1416)) +- build(deps): `golang.org/x/crypto` from 0.33.0 to 0.35.0 ([#1417](https://github.com/fastly/cli/pull/1417)) +- build(deps): `github.com/go-jose/go-jose/v4` from 4.0.4 to 4.0.5 ([#1412](https://github.com/fastly/cli/pull/1412)) +- build(deps): `github.com/klauspost/compress` from 1.17.11 to 1.18.0 ([#1411](https://github.com/fastly/cli/pull/1411)) +- build(deps): `github.com/google/go-cmp` from 0.6.0 to 0.7.0 ([#1409](https://github.com/fastly/cli/pull/1409)) +- build(deps): `golang.org/x/oauth2` from 0.26.0 to 0.27.0 ([#1421](https://github.com/fastly/cli/pull/1421)) +- build(deps): `github.com/hashicorp/cap` from 0.8.0 to 0.9.0 ([#1422](https://github.com/fastly/cli/pull/1422)) +- build(deps): `github.com/fastly/go-fastly/v9` from 9.13.1 to 9.14.0 ([#1433](https://github.com/fastly/cli/pull/1433)) +- build(deps): `golang.org/x/crypto` from 0.35.0 to 0.36.0 ([#1430](https://github.com/fastly/cli/pull/1430)) +- build(deps): `golang.org/x/oauth2` from 0.27.0 to 0.28.0 ([#1432](https://github.com/fastly/cli/pull/1432)) +- build(deps): `golang.org/x/net` from 0.35.0 to 0.37.0 ([#1439](https://github.com/fastly/cli/pull/1439)) +- build(deps): `github.com/golang/snappy` from 0.0.4 to 1.0.0 ([#1438](https://github.com/fastly/cli/pull/1438)) +- build(deps): `golang.org/x/mod` from 0.23.0 to 0.24.0 ([#1437](https://github.com/fastly/cli/pull/1437)) +- build(deps): `github.com/coreos/go-oidc/v3` from 3.12.0 to 3.13.0 ([#1442](https://github.com/fastly/cli/pull/1442)) + +## [v10.19.0](https://github.com/fastly/cli/releases/tag/v10.19.0) (2025-02-18) + +**Enhancements:** + +- feat(computeacl): add support for compute platform ACLs [#1388](https://github.com/fastly/cli/pull/1388) + +**Dependencies:** + +- build(deps): bump golang.org/x/net from 0.34.0 to 0.35.0 [#1394](https://github.com/fastly/cli/pull/1394) +- build(deps): bump golang.org/x/crypto from 0.32.0 to 0.33.0 [#1391](https://github.com/fastly/cli/pull/1391) +- build(deps): bump golang.org/x/term from 0.28.0 to 0.29.0[#1393](https://github.com/fastly/cli/pull/1393) +- build(deps): bump golang.org/x/oauth2 from 0.25.0 to 0.26.0 [#1390](https://github.com/fastly/cli/pull/1390) +- build(deps): bump github.com/fastly/go-fastly/v9 from 9.13.0 to 9.13.1 [#1388](https://github.com/fastly/cli/pull/1388) + +## [v10.18.0](https://github.com/fastly/cli/releases/tag/v10.18.0) (2025-01-29) + +**Enhancements:** + +- feat(domains): add support for v1 functionality [#1374](https://github.com/fastly/cli/pull/1374) +- feat(dashboard): add support for Observability custom dashboards [#1357](https://github.com/fastly/cli/pull/1357) + +**Bug fixes:** + +- fix(npm): fix build checks for JavaScript code [#1354](https://github.com/fastly/cli/pull/1354) +- fix(build): pin Rust to 1.83.0 [#1368](https://github.com/fastly/cli/pull/1368) +- fix(build): correct generation of static configuration file [#1378](https://github.com/fastly/cli/pull/1378) +- fix(kvstoreentry/create): rework --dir flag [#1371](https://github.com/fastly/cli/pull/1371) + +**Dependencies:** + +- build(deps): bump golang.org/x/crypto from 0.31.0 to 0.32.0 [#1366](https://github.com/fastly/cli/pull/1366) +- build(deps): bump golang.org/x/text from 0.20.0 to 0.21.0 [#1360](https://github.com/fastly/cli/pull/1360) +- build(deps): bump github.com/otiai10/copy from 1.14.0 to 1.14.1 [#1364](https://github.com/fastly/cli/pull/1364) +- build(deps): bump github.com/hashicorp/cap from 0.7.0 to 0.8.0 [#1365](https://github.com/fastly/cli/pull/1365) +- build(deps): bump golang.org/x/net from 0.27.0 to 0.33.0 [#1370](https://github.com/fastly/cli/pull/1370) +- build(deps): bump github.com/rogpeppe/go-internal from 1.11.0 to 1.13.1 [#1379](https://github.com/fastly/cli/pull/1379) +- build(deps): bump github.com/dustinkirkland/golang-petname from 20240428194347 to eebcea082ee0 [#1377](https://github.com/fastly/cli/pull/1377) + +## [v10.17.1](https://github.com/fastly/cli/releases/tag/v10.17.1) (2024-12-04) + +**Bug fixes:** + +- fix(sso): Ensure that only one authentication cycle is started. [#1355](https://github.com/fastly/cli/pull/1355) + +**Dependencies:** + +- build(deps): bump github.com/Masterminds/semver/v3 from 3.3.0 to 3.3.1 [#1352](https://github.com/fastly/cli/pull/1352) + +## [v10.17.0](https://github.com/fastly/cli/releases/tag/v10.17.0) (2024-11-20) + +**Enhancements:** + +- feat(compute/build): Support 'upper bound' constraints on Rust versions. [#1350](https://github.com/fastly/cli/pull/1350) + +**Bug fixes:** + +- fix(compute/init): Init no longer fails if directory of same name as starter kit exists in current directory [#1349](https://github.com/fastly/cli/pull/1349) + +## [v10.16.0](https://github.com/fastly/cli/releases/tag/v10.16.0) (2024-11-12) + +**Enhancements:** + +- Publish to GitHub packages in addition to npmjs [#1330](https://github.com/fastly/cli/pull/1330) +- feat(logging): Add support for Grafana Cloud Logs. [#1333](https://github.com/fastly/cli/pull/1333) +- feat(profile/token): Allow user to specify how long token must be valid. [#1340](https://github.com/fastly/cli/pull/1340) +- feat(products): Add support for Log Explorer & Insights. [#1341](https://github.com/fastly/cli/pull/1341) + +**Bug fixes:** + +- breaking(products): Remove support for NGWAF product. [#1338](https://github.com/fastly/cli/pull/1338) +- fix(profile/token): 'profile token' command must check the validity of the stored token. [#1339](https://github.com/fastly/cli/pull/1339) +- fix(lint): Update staticcheck and fix identified problems. [#1346](https://github.com/fastly/cli/pull/1346) + +**Dependencies:** + +- build(deps): bump golang.org/x/term from 0.24.0 to 0.25.0 [#1324](https://github.com/fastly/cli/pull/1324) +- build(deps): bump golang.org/x/crypto from 0.27.0 to 0.28.0 [#1325](https://github.com/fastly/cli/pull/1325) +- build(deps): bump github.com/fatih/color from 1.17.0 to 1.18.0 [#1331](https://github.com/fastly/cli/pull/1331) +- build(deps): bump github.com/fsnotify/fsnotify from 1.7.0 to 1.8.0 [#1334](https://github.com/fastly/cli/pull/1334) +- build(deps): Update to go-fastly v9.12.0. [#1337](https://github.com/fastly/cli/pull/1337) +- build(deps): bump golang.org/x/term from 0.25.0 to 0.26.0 [#1342](https://github.com/fastly/cli/pull/1342) +- build(deps): bump golang.org/x/crypto from 0.28.0 to 0.29.0 [#1343](https://github.com/fastly/cli/pull/1343) +- build(deps): bump golang.org/x/text from 0.19.0 to 0.20.0 [#1344](https://github.com/fastly/cli/pull/1344) +- build(deps): bump golang.org/x/mod from 0.21.0 to 0.22.0 [#1345](https://github.com/fastly/cli/pull/1345) + +## [v10.15.0](https://github.com/fastly/cli/releases/tag/v10.15.0) (2024-10-03) + +**Enhancements:** + +- feat(products): Add support for NGWAF product [#1322](https://github.com/fastly/cli/pull/1322) + +**Dependencies:** + +- build(deps): Upgrade to go-fastly 9.11.0. [#1322](https://github.com/fastly/cli/pull/1322) + +## [v10.14.1](https://github.com/fastly/cli/releases/tag/v10.14.1) (2024-09-16) + +**Bug fixes:** + +- fix(tls/subscription): Recognise Certainly CA as an option when creating TLS subscriptions. [#1315](https://github.com/fastly/cli/pull/1315) + +## [v10.14.0](https://github.com/fastly/cli/releases/tag/v10.14.0) (2024-09-10) + +**Enhancements:** + +- feat(npm): Add TypeScript types to @fastly/cli [#1296](https://github.com/fastly/cli/pull/1296) +- feat(products): Add support for Fastly Bot Management product. [#1300](https://github.com/fastly/cli/pull/1300) + +**Bug fixes:** + +- fix(compute/publish): Don't change directory twice during execution. [#1295](https://github.com/fastly/cli/pull/1295) +- feat(npm): Properly handle error from npm-invoked cli [#1302](https://github.com/fastly/cli/pull/1302) + +**Dependencies:** + +- build(deps): bump github.com/Masterminds/semver/v3 from 3.2.1 to 3.3.0 [#1306](https://github.com/fastly/cli/pull/1306) +- build(deps): bump golang.org/x/text from 0.17.0 to 0.18.0 [#1309](https://github.com/fastly/cli/pull/1309) +- build(deps): bump golang.org/x/term from 0.23.0 to 0.24.0 [#1310](https://github.com/fastly/cli/pull/1310) +- build(deps): bump golang.org/x/crypto from 0.26.0 to 0.27.0 [#1311](https://github.com/fastly/cli/pull/1311) +- build(deps): bump golang.org/x/mod from 0.20.0 to 0.21.0 [#1312](https://github.com/fastly/cli/pull/1312) + +## [v10.13.3](https://github.com/fastly/cli/releases/tag/v10.13.3) (2024-08-15) + +This release does not contain any code changes, but was made in order +to trigger the new 'NPM release' workflow after resolving some flaws +in that workflow. + +## [v10.13.2](https://github.com/fastly/cli/releases/tag/v10.13.2) (2024-08-15) + +This release does not contain any code changes, but was made in order +to trigger the new 'NPM release' workflow after resolving an +authentication flaw in that workflow. + +## [v10.13.1](https://github.com/fastly/cli/releases/tag/v10.13.1) (2024-08-14) + +This release does not contain any code changes, but was made in order +to trigger the new 'NPM release' workflow. + +## [v10.13.0](https://github.com/fastly/cli/releases/tag/v10.13.0) (2024-08-14) + +**Enhancements:** + +- feat(tls): add optional `--key-path` parameter to `tls-custom private-key create` command [#1215](https://github.com/fastly/cli/pull/1215) +- feat: add debug-mode around all network requests [#1239](https://github.com/fastly/cli/pull/1239) +- logtail: add --timestamps flag [#1254](https://github.com/fastly/cli/pull/1254) +- Distribute binaries via npm module [#1269](https://github.com/fastly/cli/pull/1269) +- Enable quiet mode when `--json` flag is supplied [#1271](https://github.com/fastly/cli/pull/1271) +- Support configuring connection keepalive parameters [#1275](https://github.com/fastly/cli/pull/1275) + +**Bug fixes:** + +- fix(update): Ensure that the CLI binary will be executable after an update [#1244](https://github.com/fastly/cli/pull/1244) +- fix(service-version): Allow 'locked' services to be activated. [#1245](https://github.com/fastly/cli/pull/1245) +- fix(compute/serve): don't fail the serve workflow if github errors [#1246](https://github.com/fastly/cli/pull/1246) +- fix(all commands): --service-name flag should have priority. [#1264](https://github.com/fastly/cli/pull/1264) +- fix(products): Display product names in API style [#1270](https://github.com/fastly/cli/pull/1270) + +**Dependencies:** + +- build(deps): bump goreleaser/goreleaser-action from 5 to 6 [#1220](https://github.com/fastly/cli/pull/1220) +- build(deps): bump golang.org/x/text from 0.15.0 to 0.16.0 [#1222](https://github.com/fastly/cli/pull/1222) +- build(deps): bump golang.org/x/mod from 0.17.0 to 0.18.0 [#1223](https://github.com/fastly/cli/pull/1223) +- build(deps): bump golang.org/x/term from 0.20.0 to 0.21.0 [#1224](https://github.com/fastly/cli/pull/1224) +- build(deps): bump golang.org/x/crypto from 0.23.0 to 0.24.0 [#1225](https://github.com/fastly/cli/pull/1225) +- build(deps): bump github.com/fastly/go-fastly/v9 from 9.5.0 to 9.7.0 [#1235](https://github.com/fastly/cli/pull/1235) +- build(deps): bump golang.org/x/term from 0.21.0 to 0.22.0 [#1240](https://github.com/fastly/cli/pull/1240) +- build(deps): bump golang.org/x/crypto from 0.24.0 to 0.25.0 [#1241](https://github.com/fastly/cli/pull/1241) +- build(deps): bump golang.org/x/mod from 0.18.0 to 0.19.0 [#1242](https://github.com/fastly/cli/pull/1242) +- build(deps): 'tomlq' package now installs a 'tq' binary [#1243](https://github.com/fastly/cli/pull/1243) +- build(deps): bump github.com/hashicorp/cap from 0.6.0 to 0.7.0 [#1272](https://github.com/fastly/cli/pull/1272) +- build(deps): bump golang.org/x/mod from 0.19.0 to 0.20.0 [#1273](https://github.com/fastly/cli/pull/1273) +- build(deps): bump golang.org/x/text from 0.16.0 to 0.17.0 [#1281](https://github.com/fastly/cli/pull/1281) +- build(deps): bump golang.org/x/crypto from 0.25.0 to 0.26.0 [#1282](https://github.com/fastly/cli/pull/1282) +- build(deps): bump golang.org/x/term from 0.22.0 to 0.23.0 [#1283](https://github.com/fastly/cli/pull/1283) + +## [v10.12.3](https://github.com/fastly/cli/releases/tag/v10.12.3) (2024-06-14) + +**Bug fixes:** + +- fix(sso): correct the behaviour for direct sso invocation [#1230](https://github.com/fastly/cli/pull/1230) +- fix(compute/deploy): dereference service number pointer [#1231](https://github.com/fastly/cli/pull/1231) +- fix(sso): update output to reflect default profile behaviour [#1232](https://github.com/fastly/cli/pull/1232) + +## [v10.12.2](https://github.com/fastly/cli/releases/tag/v10.12.2) (2024-06-13) + +**Bug fixes:** + +- fix(sso): re-auth on profile switch + support MAUA [#1226](https://github.com/fastly/cli/pull/1226) + +## [v10.12.1](https://github.com/fastly/cli/releases/tag/v10.12.1) (2024-06-10) + +**Enhancements:** + +- expose SSO commands and flags [#1218](https://github.com/fastly/cli/pull/1218) + +## [v10.12.0](https://github.com/fastly/cli/releases/tag/v10.12.0) (2024-06-10) + +**Enhancements:** + +- feat(sso): support active session account switching [#1207](https://github.com/fastly/cli/pull/1207) + +## [v10.11.0](https://github.com/fastly/cli/releases/tag/v10.11.0) (2024-06-06) + +**Enhancements:** + +- feat(app): improve error messaging when Fastly servers are unresponsive [#1212](https://github.com/fastly/cli/pull/1212) +- feat(compute): clone starter kit source with init --from=serviceID [#1213](https://github.com/fastly/cli/pull/1213) +- Adds --cert-path argument to `tls-custom certificate update` command to pass in a path to a certificate file [#1214](https://github.com/fastly/cli/pull/1214) + +## [v10.10.0](https://github.com/fastly/cli/releases/tag/v10.10.0) (2024-05-20) + +**Enhancements:** + +- Adds --cert-path argument to `tls-custom certificate create` command to pass in a path to a certificate file [#1189](https://github.com/fastly/cli/pull/1189) +- feat(observability/alerts): Alerts support [#1192](https://github.com/fastly/cli/pull/1192) +- feat(compute/rust) Handle Cargo config filename for Rust >=1.78.0 [#1199](https://github.com/fastly/cli/pull/1199) +- add project-id to gcs logging setting [#1202](https://github.com/fastly/cli/pull/1202) + +**Dependencies:** + +- build(deps): bump github.com/fastly/go-fastly/v9 from 9.3.1 to 9.3.2 [#1204](https://github.com/fastly/cli/pull/1204) +- build(deps): bump github.com/fatih/color from 1.16.0 to 1.17.0 [#1205](https://github.com/fastly/cli/pull/1205) + +## [v10.9.0](https://github.com/fastly/cli/releases/tag/v10.9.0) (2024-05-08) + +**Enhancements:** + +- chore: grammar and capitalization fixes for KV Store commands [#1178](https://github.com/fastly/cli/pull/1178) +- feat(kvstores): add support for specifying location when creating KV stores [#1182](https://github.com/fastly/cli/pull/1182) +- feat(compute/build): support wasm-tools installed into `$PATH` [#1183](https://github.com/fastly/cli/pull/1183) +- feat(compute/serve): support arbitrary arguments to Viceroy [#1186](https://github.com/fastly/cli/pull/1186) +- ci: update tinygo version used in tests [#1188](https://github.com/fastly/cli/pull/1188) +- feat(compute/init): allow `--from` to take a Service ID [#1187](https://github.com/fastly/cli/pull/1187) + +**Bug fixes:** + +- fix(kvstore): delete all keys [#1181](https://github.com/fastly/cli/pull/1181) +- fix(compute/rust) handling of 'cargo version' output [#1197](https://github.com/fastly/cli/pull/1197) +- fix(compute/serve): skip build if `--file` set [#1200](https://github.com/fastly/cli/pull/1200) + +**Dependencies:** + +- build(deps): bump github.com/fastly/go-fastly/v9 from 9.2.1 to 9.2.2 [#1180](https://github.com/fastly/cli/pull/1180) +- build(deps): bump golang.org/x/crypto from 0.22.0 to 0.23.0 [#1194](https://github.com/fastly/cli/pull/1194) + +## [v10.8.10](https://github.com/fastly/cli/releases/tag/v10.8.10) (2024-04-10) + +**Dependencies:** + +- build(deps): bump golang.org/x/crypto from 0.21.0 to 0.22.0 [#1173](https://github.com/fastly/cli/pull/1173) +- build(deps): bump golang.org/x/mod from 0.16.0 to 0.17.0 [#1175](https://github.com/fastly/cli/pull/1175) + +## [v10.8.9](https://github.com/fastly/cli/releases/tag/v10.8.9) (2024-03-27) + +**Bug fixes:** + +- fix(stats/historical): avoid runtime SIGSEGV [#1169](https://github.com/fastly/cli/pull/1169) + +## [v10.8.8](https://github.com/fastly/cli/releases/tag/v10.8.8) (2024-03-15) + +**Enhancements:** + +- feat(logging/scalyr): add project-id [#1166](https://github.com/fastly/cli/pull/1166) +- Update all URLs for developer.fastly.com to their new forms [#1164](https://github.com/fastly/cli/pull/1164) + +**Dependencies:** + +- build(deps): bump google.golang.org/protobuf from 1.28.1 to 1.33.0 [#1158](https://github.com/fastly/cli/pull/1158) + +## [v10.8.7](https://github.com/fastly/cli/releases/tag/v10.8.7) (2024-03-14) + +**Bug fixes:** + +- fix(text): deref pointers [#1161](https://github.com/fastly/cli/pull/1161) +- fix(compute/serve): let wasm-tools fail more gracefully [#1160](https://github.com/fastly/cli/pull/1160) +- fix(compute/serve): support Windows [#1159](https://github.com/fastly/cli/pull/1159) + +**Enhancements:** + +- refactor: avoid duplicate path strings [#1162](https://github.com/fastly/cli/pull/1162) + +## [v10.8.6](https://github.com/fastly/cli/releases/tag/v10.8.6) (2024-03-12) + +**Dependencies:** + +- build(deps): bump golang.org/x/crypto from 0.20.0 to 0.21.0 [#1153](https://github.com/fastly/cli/pull/1153) +- build: bump go-fastly to v9.0.1 [#1155](https://github.com/fastly/cli/pull/1155) +- build(deps): bump actions/setup-go from 4 to 5 [#1106](https://github.com/fastly/cli/pull/1106) +- build(deps): bump github.com/go-jose/go-jose/v3 from 3.0.1 to 3.0.3 [#1149](https://github.com/fastly/cli/pull/1149) +- build(deps): bump actions/download-artifact and actions/upload-artifact from 3 to 4 [#1156](https://github.com/fastly/cli/pull/1156) + +## [v10.8.5](https://github.com/fastly/cli/releases/tag/v10.8.5) (2024-03-11) + +**Bug fixes:** + +- fix(compute/serve): avoid wasm validation when --file is set [#1150](https://github.com/fastly/cli/pull/1150) + +**Enhancements:** + +- refactor(app): update list of commands that require a token [#1145](https://github.com/fastly/cli/pull/1145) + +**Dependencies:** + +- build(deps): bump golang.org/x/crypto from 0.19.0 to 0.20.0 [#1146](https://github.com/fastly/cli/pull/1146) +- build(deps): bump golang.org/x/mod from 0.15.0 to 0.16.0 [#1147](https://github.com/fastly/cli/pull/1147) + +## [v10.8.4](https://github.com/fastly/cli/releases/tag/v10.8.4) (2024-03-01) + +**Bug fixes:** + +- fix(compute/build): avoid persisting old metadata [#1142](https://github.com/fastly/cli/pull/1142) + +## [v10.8.3](https://github.com/fastly/cli/releases/tag/v10.8.3) (2024-02-21) + +**Bug fixes:** + +- fix(github): update wasm-tools path [#1136](https://github.com/fastly/cli/pull/1136) +- fix(compute/serve): avoid `text.Output` when dealing with large `bytes.Buffer` [#1138](https://github.com/fastly/cli/pull/1138) + +**Enhancements:** + +- resolve GitHub linter issues [#1137](https://github.com/fastly/cli/pull/1137) + +**Dependencies:** + +- build(deps): bump golang.org/x/mod from 0.14.0 to 0.15.0 [#1135](https://github.com/fastly/cli/pull/1135) + +## [v10.8.2](https://github.com/fastly/cli/releases/tag/v10.8.2) (2024-02-15) + +**Bug fixes:** + +- fix: directory switching logic [#1132](https://github.com/fastly/cli/pull/1132) + +## [v10.8.1](https://github.com/fastly/cli/releases/tag/v10.8.1) (2024-02-14) + +**Bug fixes:** + +- fix(compute/build): normalise and bucket heap allocations [#1130](https://github.com/fastly/cli/pull/1130) + +**Enhancements:** + +- refactor(all): support go-fastly v9 [#1124](https://github.com/fastly/cli/pull/1124) + +**Dependencies:** + +- build(deps): bump actions/cache from 3 to 4 [#1122](https://github.com/fastly/cli/pull/1122) +- build(deps): bump github.com/hashicorp/cap from 0.3.4 to 0.5.0 [#1128](https://github.com/fastly/cli/pull/1128) +- build(deps): bump golang.org/x/crypto from 0.18.0 to 0.19.0 [#1127](https://github.com/fastly/cli/pull/1127) + +## [v10.8.0](https://github.com/fastly/cli/releases/tag/v10.8.0) (2024-01-17) + +**Bug fixes:** + +- doc(tls/custom): correct flag descriptions [#1116](https://github.com/fastly/cli/pull/1116) +- fix(profile/create): support sso [#1117](https://github.com/fastly/cli/pull/1117) +- fix: update list of commands that require auth server [#1120](https://github.com/fastly/cli/pull/1120) + +**Enhancements:** + +- feat: install CLI version command [#1104](https://github.com/fastly/cli/pull/1104) +- refactor(cmd): rename package to argparser [#1105](https://github.com/fastly/cli/pull/1105) +- refactor: rename test function names [#1107](https://github.com/fastly/cli/pull/1107) + +**Dependencies:** + +- build(deps): bump golang.org/x/crypto from 0.15.0 to 0.18.0 [#1119](https://github.com/fastly/cli/pull/1119) + +## [v10.7.0](https://github.com/fastly/cli/releases/tag/v10.7.0) (2023-11-30) + +The Fastly CLI internal configuration file has `config_version` bumped to version `6`. We've added a new `[wasm-metadata.script_info]` field so that users can omit script info (which comes from the fastly.toml) from the metadata annotated onto their compiled Wasm binaries. + +When upgrading to this version of the CLI, and running a command for the first time, the config file should automatically update, but this can also be manually triggered by executing: + +```shell +fastly config --reset +``` + +**Bug fixes:** + +- fix: move auth setup so it doesn't run for non-token based commands [#1099](https://github.com/fastly/cli/pull/1099) + +**Enhancements:** + +- remove(profile/update): APIClientFactory [#1094](https://github.com/fastly/cli/pull/1094) +- feat: switch on metadata collection [#1097](https://github.com/fastly/cli/pull/1097) + +## [v10.6.4](https://github.com/fastly/cli/releases/tag/v10.6.4) (2023-11-15) + +**Bug fixes:** + +- fix(errors): ensure help output is displayed [#1092](https://github.com/fastly/cli/pull/1092) + +## [v10.6.3](https://github.com/fastly/cli/releases/tag/v10.6.3) (2023-11-15) + +The Fastly CLI internal configuration file has `config_version` bumped to version `5`. We've added a new account endpoint field (used as an override for Single-Sign On testing). + +When upgrading to this version of the CLI, and running a command for the first time, the config file should automatically update, but this can also be manually triggered by executing: + +```shell +fastly config --reset +``` + +**Bug fixes:** + +- fix(text): prompt colour [#1089](https://github.com/fastly/cli/pull/1089) +- fix(app): allow config override for account endpoint [#1090](https://github.com/fastly/cli/pull/1090) + +**Enhancements:** + +- feat: support SSO (Single Sign-On) [#1010](https://github.com/fastly/cli/pull/1010) + +**Dependencies:** + +- build(deps): bump golang.org/x/(crypto|term) [#1088](https://github.com/fastly/cli/pull/1088) + +## [v10.6.2](https://github.com/fastly/cli/releases/tag/v10.6.2) (2023-11-09) + +**Bug fixes:** + +- fix(github): corrections for Windows users downloading wasm-tools [#1083](https://github.com/fastly/cli/pull/1083) +- fix(compute/build): don't block user if wasm-tool fails [#1084](https://github.com/fastly/cli/pull/1084) + +**Enhancements:** + +- refactor: apply linting fixes [#1080](https://github.com/fastly/cli/pull/1080) +- refactor(compute/serve): replace log.Fatal usage with channel [#1081](https://github.com/fastly/cli/pull/1081) +- refactor(logtail): replace log.Fatal usage with channel [#1081](https://github.com/fastly/cli/pull/1082) + +**Dependencies:** + +- build(deps): bump golang.org/x/mod from 0.13.0 to 0.14.0 [#1079](https://github.com/fastly/cli/pull/1079) +- build(deps): bump golang.org/x/text from 0.13.0 to 0.14.0 [#1078](https://github.com/fastly/cli/pull/1078) +- build(deps): bump github.com/fatih/color from 1.15.0 to 1.16.0 [#1077](https://github.com/fastly/cli/pull/1077) + +## [v10.6.1](https://github.com/fastly/cli/releases/tag/v10.6.1) (2023-11-03) + +**Bug fixes:** + +- fix(manifest): only reset EnvVars if EnvFile set [#1073](https://github.com/fastly/cli/pull/1073) +- fix(github): check architecture when fetching wasm-tools [#1074](https://github.com/fastly/cli/pull/1074) + +## [v10.6.0](https://github.com/fastly/cli/releases/tag/v10.6.0) (2023-11-03) + +**Bug fixes:** + +- fix(backend): support disabling `ssl-check-cert` [#1055](https://github.com/fastly/cli/pull/1055) + +**Enhancements:** + +- feat(compute): add metadata subcommand [#1013](https://github.com/fastly/cli/pull/1013) +- feat(telemetry): add wasm-tools wasm binary annotations [#1016](https://github.com/fastly/cli/pull/1016) +- feat: add `--consistency` flag to `kv-store-entry list` command [#1058](https://github.com/fastly/cli/pull/1058) +- feat: add `--debug-mode` [#1056](https://github.com/fastly/cli/pull/1056) +- ci: replace setup-tinygo fork with original [#1057](https://github.com/fastly/cli/pull/1057) + +**Dependencies:** + +- build(deps): bump github.com/docker/docker [#1060](https://github.com/fastly/cli/pull/1060) +- build(deps): bump google.golang.org/grpc from 1.56.2 to 1.56.3 [#1061](https://github.com/fastly/cli/pull/1061) +- build(deps): bump all go.mod dependencies [#1062](https://github.com/fastly/cli/pull/1062) + +## [v10.5.1](https://github.com/fastly/cli/releases/tag/v10.5.1) (2023-10-25) + +**Bug fixes:** + +- fix(compute/deploy): ignore package comparison error [#1053](https://github.com/fastly/cli/pull/1053) +- remove: trufflehog [#1064](https://github.com/fastly/cli/pull/1064) +- fix(cmd/flags): handle zero length check separately [#1065](https://github.com/fastly/cli/pull/1065) +- fix(compute/deploy): only cleanup service if there is an ID [#1066](https://github.com/fastly/cli/pull/1066) + +**Enhancements:** + +- refactor(compute/deploy): add setup message for existing service users [#1052](https://github.com/fastly/cli/pull/1052) +- feat(manifest): support env_file [#1067](https://github.com/fastly/cli/pull/1067) +- fix(compute/build): improve redaction logic [#1068](https://github.com/fastly/cli/pull/1068) +- feat(compute/secrets): redact common org secrets [#1069](https://github.com/fastly/cli/pull/1069) + +**Dependencies:** + +- build(deps): bump github.com/fsnotify/fsnotify from 1.6.0 to 1.7.0 [#1050](https://github.com/fastly/cli/pull/1050) +- build(deps): bump actions/setup-node from 3 to 4 [#1051](https://github.com/fastly/cli/pull/1051) + +## [v10.5.0](https://github.com/fastly/cli/releases/tag/v10.5.0) (2023-10-18) + +The Fastly CLI internal configuration file has been updated to version `4`, with the only change being the addition of the Fastly [TinyGo Compute Starter Kit](https://github.com/fastly/compute-starter-kit-go-tinygo). + +When upgrading to this version of the CLI, and running a command for the first time, the config file should automatically update, but this can also be manually triggered by executing: + +```shell +fastly config --reset +``` + +The other change worth noting is to the parsing of the `fastly.toml` manifest file, which now supports a `file` field inside `[setup.kv_stores..items]` which can be used in place of the `value` field. Assigning a file path to the `file` field will use the content of the file as the value for the key. See: https://www.fastly.com/documentation/reference/compute/fastly-toml + +**Bug fixes:** + +- fix(compute/init): `post_init` to support `env_vars` [#1014](https://github.com/fastly/cli/pull/1014) +- fix(app): return error when input is `--` only [#1022](https://github.com/fastly/cli/pull/1022) +- fix(compute/deploy): check package before service clone [#1026](https://github.com/fastly/cli/pull/1026) +- fix: spinner wraps original error [#1029](https://github.com/fastly/cli/pull/1029) +- fix(compute/serve): ensure `--env` files are processed [#1039](https://github.com/fastly/cli/pull/1039) + +**Enhancements:** + +- add: vcl condition commands [#1008](https://github.com/fastly/cli/pull/1008) +- feat(compute/build): support `env_vars` for JavaScript/Rust [#1012](https://github.com/fastly/cli/pull/1012) +- feat(config): add tinygo starter kit [#1011](https://github.com/fastly/cli/pull/1011) +- feat(compute/serve): support guest profiler under Viceroy [#1019](https://github.com/fastly/cli/pull/1019) +- fix(packaging): Improve metadata in Linux packages [#1021](https://github.com/fastly/cli/pull/1021) +- feat(compute/build): support Cargo Workspaces [#1023](https://github.com/fastly/cli/pull/1023) +- feat(spinner): abstract common pattern [#1024](https://github.com/fastly/cli/pull/1024) +- fix(text): consistent formatting and output alignment [#1030](https://github.com/fastly/cli/pull/1030) +- feat(product_enablement): add `products` command [#1036](https://github.com/fastly/cli/pull/1036) +- fix(compute/serve): update Viceroy guest profile flag [#1033](https://github.com/fastly/cli/pull/1033) +- fix(compute/deploy): support file field for `kv_store` setup [#1040](https://github.com/fastly/cli/pull/1040) +- refactor(compute/deploy): simplify logic flows [#1032](https://github.com/fastly/cli/pull/1032) +- feat(compute/build): allow user to specify project directory to build [#1043](https://github.com/fastly/cli/pull/1043) +- feat(compute/deploy): avoid store conflicts [#1041](https://github.com/fastly/cli/pull/1041) +- feat: support `--env` flag [#1046](https://github.com/fastly/cli/pull/1046) + +**Dependencies:** + +- build(deps): bump goreleaser/goreleaser-action from 4 to 5 [#1015](https://github.com/fastly/cli/pull/1015) +- build(deps): bump golang.org/x/crypto from 0.12.0 to 0.13.0 [#1009](https://github.com/fastly/cli/pull/1009) +- build(deps): bump actions/checkout from 3 to 4 [#1006](https://github.com/fastly/cli/pull/1006) +- build(deps): bump github.com/fastly/go-fastly/v8 from 8.6.1 to 8.6.2 [#1028](https://github.com/fastly/cli/pull/1028) +- build(deps): bump github.com/otiai10/copy from 1.12.0 to 1.14.0 [#1027](https://github.com/fastly/cli/pull/1027) +- build(deps): bump golang.org/x/crypto from 0.13.0 to 0.14.0 [#1034](https://github.com/fastly/cli/pull/1034) +- build(deps): bump golang.org/x/net from 0.10.0 to 0.17.0 [#1042](https://github.com/fastly/cli/pull/1042) +- build(deps): bump github.com/google/go-cmp from 0.5.9 to 0.6.0 [#1045](https://github.com/fastly/cli/pull/1045) + +**Documentation:** + +- fix(DEVELOP.MD): clarify Go version requirement and document Rust requirement [#1017](https://github.com/fastly/cli/pull/1017) +- doc(compute/serve): update GetViceroy doc [#1038](https://github.com/fastly/cli/pull/1038) +- branding: Replace all Compute@Edge/C@E references with Compute [#1044](https://github.com/fastly/cli/pull/1044) + +## [v10.4.0](https://github.com/fastly/cli/releases/tag/v10.4.0) (2023-08-31) + +The Fastly CLI internal configuration file has been updated to version `3`, with the primary change being updates to the toolchain constraints within the `[language.go]` section ([diff](https://github.com/fastly/cli/pull/995/files#diff-8b30a64872c0f304cd83a24f92c57f62b12d6ba81c6a51428da7d1ed3ceb83fd)). + +When upgrading to this version of the CLI, and running a command for the first time, the config file should automatically update, but this can also be manually triggered by executing: + +```shell +fastly config --reset +``` + +The changes to the internal configuration correlate with another change in this release, which is adding support for standard Go alongside TinyGo. + +If your fastly.toml has no custom `[scripts.build]` defined, then TinyGo will continue to be the default compiler used for building your Compute@Edge project. Otherwise, adding the following will enable you to use the Wasm support that Go 1.21+ provides: + +```toml +[scripts] +env_vars = ["GOARCH=wasm", "GOOS=wasip1"] +build = "go build -o bin/main.wasm ." +``` + +**Deprecations:** + +- remove(compute/init): assemblyscript [#1002](https://github.com/fastly/cli/pull/1002) + +**Enhancements:** + +- feat(compute/build): support native go [#995](https://github.com/fastly/cli/pull/995) +- Add support for interacting with the New Relic OTLP logging endpoint [#990](https://github.com/fastly/cli/pull/990) + +**Dependencies:** + +- build: bump go-fastly to v8.6.1 [#1000](https://github.com/fastly/cli/pull/1000) +- build(deps): bump golang.org/x/crypto from 0.11.0 to 0.12.0 [#994](https://github.com/fastly/cli/pull/994) +- build(deps): bump github.com/fastly/go-fastly/v8 from 8.5.7 to 8.5.9 [#996](https://github.com/fastly/cli/pull/996) + +## [v10.3.0](https://github.com/fastly/cli/releases/tag/v10.3.0) (2023-08-16) + +**Enhancements:** + +- feat(compute/init): support post_init [#997](https://github.com/fastly/cli/pull/997) + +**Bug fixes:** + +- build(scripts): use /usr/bin/env bash to retrieve system bash path [#987](https://github.com/fastly/cli/pull/987) +- fix(kvstores/list): support pagination [#988](https://github.com/fastly/cli/pull/988) +- fix(secretstore): pagination + support for json [#991](https://github.com/fastly/cli/pull/991) + +## [v10.2.4](https://github.com/fastly/cli/releases/tag/v10.2.4) (2023-07-28) + +**Enhancements:** + +- fix(kvstoreentry): improve error handling for batch processing [#980](https://github.com/fastly/cli/pull/980) +- feat(kvstore): support deleting all keys [#981](https://github.com/fastly/cli/pull/981) +- feat(configstoreentry): support deleting all keys [#983](https://github.com/fastly/cli/pull/983) + +**Bug fixes:** + +- fix(compute/deploy): support --service-name for publishing to a non-manifest specific service [#979](https://github.com/fastly/cli/pull/979) +- fix(compute/validate): remove broken decompression bomb check [#984](https://github.com/fastly/cli/pull/984) + +## [v10.2.3](https://github.com/fastly/cli/releases/tag/v10.2.3) (2023-07-20) + +**Enhancements:** + +- refactor(compute): clean-up logic surrounding filesHash generation [#969](https://github.com/fastly/cli/pull/969) +- fix: increase text width [#970](https://github.com/fastly/cli/pull/970) + +**Bug fixes:** + +- Correctly check if the package is up to date [#967](https://github.com/fastly/cli/pull/967) +- fix(flags): ensure ListServices call is paginated [#976](https://github.com/fastly/cli/pull/976) + +**Dependencies:** + +- build(deps): bump github.com/fastly/go-fastly/v8 from 8.5.1 to 8.5.2 [#966](https://github.com/fastly/cli/pull/966) +- build(deps): bump github.com/fastly/go-fastly/v8 from 8.5.2 to 8.5.4 [#968](https://github.com/fastly/cli/pull/968) +- build(deps): bump golang.org/x/crypto from 0.10.0 to 0.11.0 [#972](https://github.com/fastly/cli/pull/972) +- build(deps): bump golang.org/x/term from 0.9.0 to 0.10.0 [#971](https://github.com/fastly/cli/pull/971) + +## [v10.2.2](https://github.com/fastly/cli/releases/tag/v10.2.2) (2023-06-22) + +**Enhancements:** + +- refactor(ci): disable setup-go caching to avoid later cache restoration errors [#960](https://github.com/fastly/cli/pull/960) + +**Bug fixes:** + +- fix(update): use consistent pattern for replacing binary [#961](https://github.com/fastly/cli/pull/961) +- fix(kvstoreentry): avoid runtime panic for out of bound slice index [#964](https://github.com/fastly/cli/pull/964) + +**Dependencies:** + +- build(deps): bump golang.org/x/term from 0.8.0 to 0.9.0 [#959](https://github.com/fastly/cli/pull/959) +- build(deps): bump github.com/otiai10/copy from 1.11.0 to 1.12.0 [#958](https://github.com/fastly/cli/pull/958) +- build(deps): bump golang.org/x/crypto from 0.9.0 to 0.10.0 [#957](https://github.com/fastly/cli/pull/957) + +## [v10.2.1](https://github.com/fastly/cli/releases/tag/v10.2.1) (2023-06-19) + +**Enhancements:** + +- feat(logging/s3): add --file-max-bytes flag [#952](https://github.com/fastly/cli/pull/952) +- ci: better caching support [#951](https://github.com/fastly/cli/pull/951) +- fix: remove sentry [#954](https://github.com/fastly/cli/pull/954) +- refactor: logic clean-up [#955](https://github.com/fastly/cli/pull/955) + +**Bug fixes:** + +- ci: fix cache restore bug [#953](https://github.com/fastly/cli/pull/953) + +**Dependencies:** + +- build(deps): bump github.com/fastly/go-fastly/v8 from 8.4.1 to 8.5.0 [#949](https://github.com/fastly/cli/pull/949) + +## [v10.2.0](https://github.com/fastly/cli/releases/tag/v10.2.0) (2023-06-12) + +**Enhancements:** + +- feat: support viceroy pinning [#947](https://github.com/fastly/cli/pull/947) +- Enable environment variable hints for `--token` flag [#945](https://github.com/fastly/cli/pull/945) +- secret store: add `--recreate` and `--recreate-must` options [#930](https://github.com/fastly/cli/pull/930) + +**Dependencies:** + +- build(deps): bump github.com/fastly/go-fastly/v8 from 8.3.0 to 8.4.1 [#946](https://github.com/fastly/cli/pull/946) + +## [v10.1.0](https://github.com/fastly/cli/releases/tag/v10.1.0) (2023-05-18) + +Deprecation notice: `fastly compute hashsum` is being phased out in favour of `fastly compute hash-files`. + +**Enhancements:** + +- feat(compute/hashfiles): add hash-files subcommand [#943](https://github.com/fastly/cli/pull/943) + +## [v10.0.1](https://github.com/fastly/cli/releases/tag/v10.0.1) (2023-05-17) + +**Bug fixes:** + +- fix(kvstoreentry): support JSON output for bulk processing [#940](https://github.com/fastly/cli/pull/940) + +## [v10.0.0](https://github.com/fastly/cli/releases/tag/v10.0.0) (2023-05-16) + +**Breaking:** + +This release introduces a breaking interface change to the `kv-store-entry` command. The `--key-name` flag is renamed to `--key` to be consistent with the other 'stores' supported within the CLI. + +**Bug fixes:** + +- fastly backend create: override host cannot be an empty string [#936](https://github.com/fastly/cli/pull/936) +- fix(profile): support automation tokens [#938](https://github.com/fastly/cli/pull/938) + +**Enhancements:** + +- feat(kvstore): Bulk Import [#927](https://github.com/fastly/cli/pull/927) +- refactor: make config/kv/secret store output consistent [#937](https://github.com/fastly/cli/pull/937) + +**Dependencies:** + +- build(deps): bump github.com/fastly/go-fastly/v8 from 8.0.0 to 8.0.1 [#926](https://github.com/fastly/cli/pull/926) +- build(deps): bump golang.org/x/term from 0.7.0 to 0.8.0 [#928](https://github.com/fastly/cli/pull/928) +- build(deps): bump github.com/getsentry/sentry-go from 0.20.0 to 0.21.0 [#929](https://github.com/fastly/cli/pull/929) +- build(deps): bump golang.org/x/crypto from 0.8.0 to 0.9.0 [#934](https://github.com/fastly/cli/pull/934) + +## [v9.0.3](https://github.com/fastly/cli/releases/tag/v9.0.3) (2023-04-26) + +**Bug fixes:** + +- Omit errors from Sentry reporting [#922](https://github.com/fastly/cli/pull/922) + +**Enhancements:** + +- fix(compute/serve): always set verbose mode for viceroy [#924](https://github.com/fastly/cli/pull/924) + +**Dependencies:** + +- build(deps): bump github.com/otiai10/copy from 1.10.0 to 1.11.0 [#923](https://github.com/fastly/cli/pull/923) + +## [v9.0.2](https://github.com/fastly/cli/releases/tag/v9.0.2) (2023-04-19) + +**Bug fixes:** + +- fix(kvstore): alias `object-store` [#920](https://github.com/fastly/cli/pull/920) + +## [v9.0.1](https://github.com/fastly/cli/releases/tag/v9.0.1) (2023-04-19) + +**Bug fixes:** + +- fix: reinstate support for `[setup.object_stores]` [#918](https://github.com/fastly/cli/pull/918) + +## [v9.0.0](https://github.com/fastly/cli/releases/tag/v9.0.0) (2023-04-19) + +There are a couple of important 'breaking' changes in this release. + +The `object-store` command has been renamed to `kv-store` and the `fastly.toml` manifest (used by the Fastly CLI) has updated its data model (see https://www.fastly.com/documentation/reference/compute/fastly-toml) by renaming `[setup.dictionaries]` and `[local_server.dictionaries]` to their `config_stores` equivalent, which has resulted in a new `manifest_version` number due to the change to the consumer interface. + +**Breaking:** + +- breaking(objectstore): rename object-store command to kv-store [#904](https://github.com/fastly/cli/pull/904) +- breaking(manifest): support latest fastly.toml manifest data model [#907](https://github.com/fastly/cli/pull/907) + +**Bug fixes:** + +- fix(manifest): re-raise remediation error to avoid go-toml wrapping issue [#915](https://github.com/fastly/cli/pull/915) +- fix(compute/deploy): shorten message to avoid spinner bug [#916](https://github.com/fastly/cli/pull/916) + +**Enhancements:** + +- feat(compute/deploy): add Secret Store to manifest `[setup]` [#769](https://github.com/fastly/cli/pull/769) +- feat(config): add TypeScript Starter Kit [#908](https://github.com/fastly/cli/pull/908) +- fix(errors/log): support filtering errors to Sentry [#909](https://github.com/fastly/cli/pull/909) +- fix(manifest): improve output message for fastly.toml related errors [#910](https://github.com/fastly/cli/pull/910) +- feat(service): support `--json` for service search subcommand [#911](https://github.com/fastly/cli/pull/911) +- refactor: consistent `--json` implementation [#912](https://github.com/fastly/cli/pull/912) + +**Dependencies:** + +- build(deps): bump github.com/otiai10/copy from 1.9.0 to 1.10.0 [#900](https://github.com/fastly/cli/pull/900) +- build(deps): bump golang.org/x/crypto from 0.7.0 to 0.8.0 [#901](https://github.com/fastly/cli/pull/901) +- build(deps): bump golang.org/x/term from 0.6.0 to 0.7.0 [#902](https://github.com/fastly/cli/pull/902) +- build(deps): bump github.com/Masterminds/semver/v3 from 3.2.0 to 3.2.1 [#903](https://github.com/fastly/cli/pull/903) + +## [v8.2.4](https://github.com/fastly/cli/releases/tag/v8.2.4) (2023-04-06) + +**Enhancements:** + +- feat(compute/serve): support forcing a viceroy check [#898](https://github.com/fastly/cli/pull/898) + +## [v8.2.3](https://github.com/fastly/cli/releases/tag/v8.2.3) (2023-04-05) + +[Full Changelog](https://github.com/fastly/cli/compare/v8.2.2...v8.2.3) + +**Enhancements:** + +- fix(profile): always prompt for token [#896](https://github.com/fastly/cli/pull/896) + +**Dependencies:** + +- build(deps): bump github.com/getsentry/sentry-go from 0.19.0 to 0.20.0 [#895](https://github.com/fastly/cli/pull/895) +- build(deps): bump actions/setup-go from 3 to 4 [#882](https://github.com/fastly/cli/pull/882) +- build(deps): bump github.com/fatih/color from 1.14.1 to 1.15.0 [#865](https://github.com/fastly/cli/pull/865) + +## [v8.2.2](https://github.com/fastly/cli/releases/tag/v8.2.2) (2023-03-31) + +[Full Changelog](https://github.com/fastly/cli/compare/v8.2.1...v8.2.2) + +**Enhancements:** + +- feat(ratelimit): add missing properties [#891](https://github.com/fastly/cli/pull/891) +- feat(ratelimiter): add uri-dict-name flag [#892](https://github.com/fastly/cli/pull/892) + +## [v8.2.1](https://github.com/fastly/cli/releases/tag/v8.2.1) (2023-03-30) + +[Full Changelog](https://github.com/fastly/cli/compare/v8.2.0...v8.2.1) + +**Dependencies:** + +- build(deps): bump tinygo baseline version [#888](https://github.com/fastly/cli/pull/888) + +**Bug fixes:** + +- fix(toolchain): bump go version to align with updated tinygo constraint [#889](https://github.com/fastly/cli/pull/889) + +## [v8.2.0](https://github.com/fastly/cli/releases/tag/v8.2.0) (2023-03-28) + +[Full Changelog](https://github.com/fastly/cli/compare/v8.1.2...v8.2.0) + +**Enhancements:** + +- feat(ratelimit): implement rate-limiter API [#886](https://github.com/fastly/cli/pull/886) + +## [v8.1.2](https://github.com/fastly/cli/releases/tag/v8.1.2) (2023-03-21) + +[Full Changelog](https://github.com/fastly/cli/compare/v8.1.1...v8.1.2) + +**Bug fixes:** + +- fix(service/create): input.Type assigned wrong value [#881](https://github.com/fastly/cli/pull/881) + +## [v8.1.1](https://github.com/fastly/cli/releases/tag/v8.1.1) (2023-03-20) + +[Full Changelog](https://github.com/fastly/cli/compare/v8.1.0...v8.1.1) + +**Bug fixes:** + +- Pass verbosity flag along to viceroy binary [#878](https://github.com/fastly/cli/pull/878) +- fix(compute/serve): always display local server address [#879](https://github.com/fastly/cli/pull/879) + +## [v8.1.0](https://github.com/fastly/cli/releases/tag/v8.1.0) (2023-03-17) + +[Full Changelog](https://github.com/fastly/cli/compare/v8.0.1...v8.1.0) + +**Enhancements:** + +- fix various lint and CI issues [#873](https://github.com/fastly/cli/pull/873) +- feat(config-store): Add Config Store commands [#829](https://github.com/fastly/cli/pull/829) +- fix(compute/deploy): service availability [#875](https://github.com/fastly/cli/pull/875) +- fix(compute/deploy): check status code range [#876](https://github.com/fastly/cli/pull/876) + +## [v8.0.1](https://github.com/fastly/cli/releases/tag/v8.0.1) (2023-03-15) + +[Full Changelog](https://github.com/fastly/cli/compare/v8.0.0...v8.0.1) + +**Bug fixes:** + +- fix(compute/serve): stop spinner before starting another instance [#867](https://github.com/fastly/cli/pull/867) +- fix(http/client): address confusion with timeout error [#869](https://github.com/fastly/cli/pull/869) +- fix(http/client): bump timeout to account for poor network conditions [#870](https://github.com/fastly/cli/pull/870) + +**Enhancements:** + +- refactor(compute/deploy): change default port from 80 to 443 [#866](https://github.com/fastly/cli/pull/866) + +## [v8.0.0](https://github.com/fastly/cli/releases/tag/v8.0.0) (2023-03-08) + +[Full Changelog](https://github.com/fastly/cli/compare/v7.0.1...v8.0.0) + +**Breaking:** + +This release contains a small breaking interface change that has required us to bump to a new major version. + +When viewing a profile token using `fastly profile token` the `--name` flag is no longer supported. It has been moved to a positional argument to make it consistent with the other profile subcommands. The `profile token` command now respects the global `--profile` flag, which allows a user to switch profiles for the lifetime of a single command execution. + +Examples: + +- `fastly profile token --name=example` -> `fastly profile token example` +- `fastly profile token --profile=example` + +* breaking(profiles): replace `--name` with positional arg + allow global override [#862](https://github.com/fastly/cli/pull/862) + +**Bug fixes:** + +- fix(build): show build output with `--verbose` flag [#853](https://github.com/fastly/cli/pull/853) + +**Enhancements:** + +- fix(profile/update): update active profile as default behaviour [#857](https://github.com/fastly/cli/pull/857) +- fix(compute/serve): allow overriding of viceroy binary [#859](https://github.com/fastly/cli/pull/859) +- feat(compute/deploy): check service availability [#860](https://github.com/fastly/cli/pull/860) + +**Dependencies:** + +- build(deps): bump github.com/getsentry/sentry-go from 0.18.0 to 0.19.0 [#856](https://github.com/fastly/cli/pull/856) +- build(deps): bump golang.org/x/crypto [#855](https://github.com/fastly/cli/pull/855) + +## [v7.0.1](https://github.com/fastly/cli/releases/tag/v7.0.1) (2023-03-02) + +[Full Changelog](https://github.com/fastly/cli/compare/v7.0.0...v7.0.1) + +**Bug fixes:** + +- fix(compute/build): move log calls before subprocess call [#847](https://github.com/fastly/cli/pull/847) +- fix(compute/serve): ensure spinner is closed for all logic branches [#849](https://github.com/fastly/cli/pull/849) + +**Enhancements:** + +- feat(dict/create): display dictionary ID on creation [#848](https://github.com/fastly/cli/pull/848) +- refactor: clean-up nil error checks [#851](https://github.com/fastly/cli/pull/851) + +## [v7.0.0](https://github.com/fastly/cli/releases/tag/v7.0.0) (2023-02-23) + +[Full Changelog](https://github.com/fastly/cli/compare/v6.0.6...v7.0.0) + +**Breaking:** + +There are a couple of small breaking changes to the CLI. + +Prior versions of the CLI would consult the following files to ignore specific files while running `compute serve --watch`: + +- `.ignore` +- `.gitignore` +- The user's global git ignore configuration + +We are dropping support for these files and will instead consult `.fastlyignore`, which is already used by `compute build`. + +We've removed support for the `logging logentries` subcommand as the third-party logging product has been deprecated. + +- fix(compute/serve): replace separate ignore files with `.fastlyignore` [#834](https://github.com/fastly/cli/pull/834) +- breaking(logging): remove logentries [#835](https://github.com/fastly/cli/pull/835) + +**Bug fixes:** + +- fix(compute/build): ignore all files except manifest and wasm binary [#836](https://github.com/fastly/cli/pull/836) +- fix(compute/serve): output rendering [#839](https://github.com/fastly/cli/pull/839) +- Fix compute build rendered output [#842](https://github.com/fastly/cli/pull/842) + +**Enhancements:** + +- use secret store client keys when creating secret store entries [#805](https://github.com/fastly/cli/pull/805) +- fix(compute/serve): check for missing override_host [#832](https://github.com/fastly/cli/pull/832) +- feat(resource-link): Add Service Resource commands [#800](https://github.com/fastly/cli/pull/800) +- Replace custom spinner with less buggy third-party package [#838](https://github.com/fastly/cli/pull/838) +- refactor(spinner): hide `...` after spinner has stopped [#840](https://github.com/fastly/cli/pull/840) +- fix(compute/serve): make override_host a default behaviour [#843](https://github.com/fastly/cli/pull/843) + +**Dependencies:** + +- build(deps): bump golang.org/x/net from 0.2.0 to 0.7.0 [#830](https://github.com/fastly/cli/pull/830) +- build(deps): bump github.com/fastly/go-fastly/v7 from 7.2.0 to 7.3.0 [#831](https://github.com/fastly/cli/pull/831) + +**Clean-ups:** + +- refactor: linter issues resolved [#833](https://github.com/fastly/cli/pull/833) + +## [v6.0.6](https://github.com/fastly/cli/releases/tag/v6.0.6) (2023-02-15) + +[Full Changelog](https://github.com/fastly/cli/compare/v6.0.5...v6.0.6) + +**Bug fixes:** + +- build(goreleaser): build with explicit `CGO_ENABLED=0` [#826](https://github.com/fastly/cli/pull/826) + +## [v6.0.5](https://github.com/fastly/cli/releases/tag/v6.0.5) (2023-02-15) + +[Full Changelog](https://github.com/fastly/cli/compare/v6.0.4...v6.0.5) + +**Enhancements:** + +- fix(dns): migrate to go1.20 [#824](https://github.com/fastly/cli/pull/824) + +## [v6.0.4](https://github.com/fastly/cli/releases/tag/v6.0.4) (2023-02-13) + +[Full Changelog](https://github.com/fastly/cli/compare/v6.0.3...v6.0.4) + +**Bug fixes:** + +- fix(compute/build): only use default build script if none defined [#814](https://github.com/fastly/cli/pull/814) +- fix(compute/deploy): replace spinner implementation [#820](https://github.com/fastly/cli/pull/820) + +**Enhancements:** + +- fix(compute/build): ensure build output doesn't show unless --verbose is set [#815](https://github.com/fastly/cli/pull/815) + +**Documentation:** + +- docs: remove --skip-verification [#816](https://github.com/fastly/cli/pull/816) + +**Dependencies:** + +- build(deps): bump github.com/fastly/go-fastly/v7 from 7.1.0 to 7.2.0 [#819](https://github.com/fastly/cli/pull/819) +- build(deps): bump github.com/getsentry/sentry-go from 0.17.0 to 0.18.0 [#818](https://github.com/fastly/cli/pull/818) +- build(deps): bump golang.org/x/term from 0.4.0 to 0.5.0 [#817](https://github.com/fastly/cli/pull/817) + +## [v6.0.3](https://github.com/fastly/cli/releases/tag/v6.0.3) (2023-02-09) + +[Full Changelog](https://github.com/fastly/cli/compare/v6.0.2...v6.0.3) + +**Bug fixes:** + +- fix(compute/setup): fix duplicated domains [#808](https://github.com/fastly/cli/pull/808) +- fix(setup/domain): allow user to correct a domain already in use [#811](https://github.com/fastly/cli/pull/811) + +**Enhancements:** + +- build(goreleaser): replace deprecated flag [#807](https://github.com/fastly/cli/pull/807) +- refactor: add type annotations [#809](https://github.com/fastly/cli/pull/809) +- build(lint): implement semgrep for local validation [#810](https://github.com/fastly/cli/pull/810) + +## [v6.0.2](https://github.com/fastly/cli/releases/tag/v6.0.2) (2023-02-08) + +[Full Changelog](https://github.com/fastly/cli/compare/v6.0.1...v6.0.2) + +**Bug fixes:** + +- fix(compute/build): ensure we only parse stdout from cargo command [#804](https://github.com/fastly/cli/pull/804) + +## [v6.0.1](https://github.com/fastly/cli/releases/tag/v6.0.1) (2023-02-08) + +[Full Changelog](https://github.com/fastly/cli/compare/v6.0.0...v6.0.1) + +**Enhancements:** + +- refactor(compute): add command output when there is an error [#801](https://github.com/fastly/cli/pull/801) + +## [v6.0.0](https://github.com/fastly/cli/releases/tag/v6.0.0) (2023-02-07) + +[Full Changelog](https://github.com/fastly/cli/compare/v5.1.1...v6.0.0) + +**Breaking:** + +There are three breaking changes in this release. + +The first comes from the removal of logic related to user environment +validation. This logic existed as an attempt to reduce the number of possible +errors when trying to compile a Compute project. The reality was that this +validation logic was tightly coupled to specific expectations of the CLI +(and of its starter kits) and consequently resulted in errors that were often +difficult to understand and debug, as well as restricting users from using their +own tools and scripts. By simplifying the logic flow we hope to reduce friction +for users who want to switch the tooling used, as well as reduce the general +confusion caused for users when there are environment validation errors, while +also reducing the maintenance overhead for contributors to the CLI code base. +This change has meant we no longer need to define a `--skip-validation` flag and +that resulted in a breaking interface change. + +The second breaking change is to the `objectstore` command. This has now been +renamed to `object-store`. Additionally, there is no `keys`, `get` or `insert` +commands for manipulating the object-store entries. These operations have been +moved to a separate subcommand `object-store-entry` as this keeps the naming and +structural convention consistent with ACLs and Edge Dictionaries. + +The third breaking change is to Edge Dictionaries, which sees the +`dictionary-item` subcommand renamed to `dictionary-entry`, again for +consistency with other similar subcommands. + +- Remove user environment validation logic [#785](https://github.com/fastly/cli/pull/785) +- Rework objectstore subcommand [#792](https://github.com/fastly/cli/pull/792) +- Rename object store 'keys' to 'entry' for consistency [#795](https://github.com/fastly/cli/pull/795) +- refactor(dictionaryitem): rename from item to entry [#798](https://github.com/fastly/cli/pull/798) + +**Bug fixes:** + +- Fix description in the manifest [#788](https://github.com/fastly/cli/pull/788) + +**Enhancements:** + +- Update `local_server` object and secret store formats [#789](https://github.com/fastly/cli/pull/789) + +**Clean-ups:** + +- refactor: move global struct and config.Source types into separate packages [#796](https://github.com/fastly/cli/pull/796) +- refactor(secretstore): divide command files into separate packages [#797](https://github.com/fastly/cli/pull/797) + +## [v5.1.1](https://github.com/fastly/cli/releases/tag/v5.1.1) (2023-02-01) + +[Full Changelog](https://github.com/fastly/cli/compare/v5.1.0...v5.1.1) + +**Bug fixes:** + +- fix(compute/build): AssemblyScript bugs [#786](https://github.com/fastly/cli/pull/786) + +**Dependencies:** + +- Bump github.com/fatih/color from 1.14.0 to 1.14.1 [#783](https://github.com/fastly/cli/pull/783) + +## [v5.1.0](https://github.com/fastly/cli/releases/tag/v5.1.0) (2023-01-27) + +[Full Changelog](https://github.com/fastly/cli/compare/v5.0.0...v5.1.0) + +**Enhancements:** + +- Add Secret Store support [#717](https://github.com/fastly/cli/pull/717) +- refactor(compute/deploy): reduce size of `Exec()` [#775](https://github.com/fastly/cli/pull/775) +- refactor(compute/deploy): add messaging to explain `[setup]` [#779](https://github.com/fastly/cli/pull/779) + +**Bug fixes:** + +- fix(objectstore/get): output value unless verbose/json flag passed [#774](https://github.com/fastly/cli/pull/774) +- fix(compute/init): add warning for paths with spaces [#778](https://github.com/fastly/cli/pull/778) +- fix(compute/deploy): clean-up new service creation on-error [#776](https://github.com/fastly/cli/pull/776) + +**Dependencies:** + +- Bump github.com/fatih/color from 1.13.0 to 1.14.0 [#772](https://github.com/fastly/cli/pull/772) + +## [v5.0.0](https://github.com/fastly/cli/releases/tag/v5.0.0) (2023-01-19) + +[Full Changelog](https://github.com/fastly/cli/compare/v4.6.2...v5.0.0) + +**Breaking:** + +The `objectstore` command was incorrectly configured to have a long flag using +a single character (e.g. `--k` and `--v`). These were corrected to `--key` and +`--value` (and a short flag variant for `-k` was added as well). + +- feat(objectstore): add --json support to keys/list subcommands [#762](https://github.com/fastly/cli/pull/762) +- feat(objectstore/get): implement --json flag for getting key value [#763](https://github.com/fastly/cli/pull/763) + +**Enhancements:** + +- feat(compute/deploy): add Object Store to manifest \[setup\] [#764](https://github.com/fastly/cli/pull/764) +- feat(compute/build): support locating language manifests outside project directory [#765](https://github.com/fastly/cli/pull/765) +- feat(compute/serve): implement --watch-dir flag [#758](https://github.com/fastly/cli/pull/758) + +**Bug fixes:** + +- fix(setup): object_store needs to be linked to service [#767](https://github.com/fastly/cli/pull/767) + +**Dependencies:** + +- Bump github.com/getsentry/sentry-go from 0.16.0 to 0.17.0 [#759](https://github.com/fastly/cli/pull/759) + +## [v4.6.2](https://github.com/fastly/cli/releases/tag/v4.6.2) (2023-01-12) + +[Full Changelog](https://github.com/fastly/cli/compare/v4.6.1...v4.6.2) + +**Bug fixes:** + +- fix(pkg/commands/compute/serve): prevent 386 arch users executing command [#753](https://github.com/fastly/cli/pull/753) +- build(goreleaser): fix Windows archive generation to include zips [#756](https://github.com/fastly/cli/pull/756) + +**Dependencies:** + +- Bump golang.org/x/term from 0.3.0 to 0.4.0 [#754](https://github.com/fastly/cli/pull/754) + +## [v4.6.1](https://github.com/fastly/cli/releases/tag/v4.6.1) (2023-01-05) + +[Full Changelog](https://github.com/fastly/cli/compare/v4.6.0...v4.6.1) + +**Bug fixes:** + +- fix(pkg/commands/vcl/snippet): set default dynamic value [#751](https://github.com/fastly/cli/pull/751) + +**Dependencies:** + +- Bump github.com/mattn/go-isatty from 0.0.16 to 0.0.17 [#748](https://github.com/fastly/cli/pull/748) + +**Enhancements:** + +- build(makefile): add goreleaser target for testing builds locally [#750](https://github.com/fastly/cli/pull/750) + +## [v4.6.0](https://github.com/fastly/cli/releases/tag/v4.6.0) (2023-01-03) + +[Full Changelog](https://github.com/fastly/cli/compare/v4.5.0...v4.6.0) + +**Bug fixes:** + +- vcl/snippet: pass AllowActiveLocked if --dynamic was passed [#742](https://github.com/fastly/cli/pull/742) + +**Dependencies:** + +- Bump goreleaser/goreleaser-action from 3 to 4 [#746](https://github.com/fastly/cli/pull/746) + +**Enhancements:** + +- Use DevHub endpoint for acquiring CLI/Viceroy metadata [#739](https://github.com/fastly/cli/pull/739) + +## [v4.5.0](https://github.com/fastly/cli/releases/tag/v4.5.0) (2022-12-15) + +[Full Changelog](https://github.com/fastly/cli/compare/v4.4.1...v4.5.0) + +**Documentation:** + +- docs(pkg/compute): remove PLC labels from supported languages [#740](https://github.com/fastly/cli/pull/740) + +**Enhancements:** + +- refactor(pkg/commands/update): move versioner logic to separate package [#735](https://github.com/fastly/cli/pull/735) +- fix(compute): don't validate js-compute-runtime binary location [#731](https://github.com/fastly/cli/pull/731) +- Link to Starter Kits during compute init [#730](https://github.com/fastly/cli/pull/730) +- Update tinygo default build command [#727](https://github.com/fastly/cli/pull/727) +- CI/Dockerfiles: minor dockerfiles improvements [#722](https://github.com/fastly/cli/pull/722) +- Switch JavaScript build script based on webpack in package.json prebuild [#728](https://github.com/fastly/cli/pull/728) +- Add support for TOML secret_store section [#726](https://github.com/fastly/cli/pull/726) + +**Dependencies:** + +- Bump golang.org/x/term from 0.2.0 to 0.3.0 [#733](https://github.com/fastly/cli/pull/733) +- Bump github.com/getsentry/sentry-go from 0.15.0 to 0.16.0 [#734](https://github.com/fastly/cli/pull/734) +- Bump github.com/Masterminds/semver/v3 from 3.1.1 to 3.2.0 [#724](https://github.com/fastly/cli/pull/724) + +## [v4.4.1](https://github.com/fastly/cli/releases/tag/v4.4.1) (2022-12-02) + +[Full Changelog](https://github.com/fastly/cli/compare/v4.4.0...v4.4.1) + +**Bug fixes:** + +- Avoid sending empty string to Backend create API [#720](https://github.com/fastly/cli/pull/720) + +## [v4.4.0](https://github.com/fastly/cli/releases/tag/v4.4.0) (2022-11-29) + +[Full Changelog](https://github.com/fastly/cli/compare/v4.3.0...v4.4.0) + +**Enhancements:** + +- Add `PrintLines` to `text` package and use in logging [#698](https://github.com/fastly/cli/pull/698) +- Add dependabot workflow automation for updating dependency [#701](https://github.com/fastly/cli/pull/701) +- Add account name to pubsub and bigquery [#699](https://github.com/fastly/cli/pull/699) + +**Bug fixes:** + +- Add missing `--help` flag to globals [#695](https://github.com/fastly/cli/pull/695) +- Fix check for mutual exclusion flags [#696](https://github.com/fastly/cli/pull/696) +- Fix object store TOML definitions, add test data [#715](https://github.com/fastly/cli/pull/715) + +**Dependencies:** + +- Bump github.com/otiai10/copy from 1.7.0 to 1.9.0 [#706](https://github.com/fastly/cli/pull/706) +- Bump github.com/mholt/archiver/v3 from 3.5.0 to 3.5.1 [#703](https://github.com/fastly/cli/pull/703) +- Bump github.com/fastly/go-fastly/v6 from 6.6.0 to 6.8.0 [#704](https://github.com/fastly/cli/pull/704) +- Bump github.com/mattn/go-isatty from 0.0.14 to 0.0.16 [#702](https://github.com/fastly/cli/pull/702) +- Bump github.com/google/go-cmp from 0.5.6 to 0.5.9 [#708](https://github.com/fastly/cli/pull/708) +- Bump github.com/mitchellh/mapstructure from 1.4.3 to 1.5.0 [#709](https://github.com/fastly/cli/pull/709) +- Bump github.com/bep/debounce from 1.2.0 to 1.2.1 [#711](https://github.com/fastly/cli/pull/711) +- Bump github.com/getsentry/sentry-go from 0.12.0 to 0.15.0 [#712](https://github.com/fastly/cli/pull/712) +- Bump github.com/pelletier/go-toml from 1.9.3 to 1.9.5 [#710](https://github.com/fastly/cli/pull/710) +- Bump go-fastly to v7 [#707](https://github.com/fastly/cli/pull/707) +- Bump github.com/fsnotify/fsnotify from 1.5.1 to 1.6.0 [#716](https://github.com/fastly/cli/pull/716) + +## [v4.3.0](https://github.com/fastly/cli/releases/tag/v4.3.0) (2022-10-26) + +[Full Changelog](https://github.com/fastly/cli/compare/v4.2.0...v4.3.0) + +**Enhancements:** + +- Fix release process to not use external config [#688](https://github.com/fastly/cli/pull/688) +- Skip exit code 1 for 'help' output [#689](https://github.com/fastly/cli/pull/689) +- Implement dynamic package name [#686](https://github.com/fastly/cli/pull/686) +- Replace fiddle.fastly.dev with fiddle.fastlydemo.net [#687](https://github.com/fastly/cli/pull/687) +- Code clean-up [#685](https://github.com/fastly/cli/pull/685) +- Implement --quiet flag [#690](https://github.com/fastly/cli/pull/690) +- Make `compute build` respect `--quiet` [#694](https://github.com/fastly/cli/pull/694) + +**Bug fixes:** + +- Fix runtime panic [#683](https://github.com/fastly/cli/pull/683) +- Fix runtime panic caused by outdated global flags [#693](https://github.com/fastly/cli/pull/693) +- Fix runtime panic caused by missing `--help` global flag [#695](https://github.com/fastly/cli/pull/695) +- Fix check for mutual exclusion flags[#696](https://github.com/fastly/cli/pull/696) +- Correct installation instructions for Go [#682](https://github.com/fastly/cli/pull/682) + +## [v4.2.0](https://github.com/fastly/cli/releases/tag/v4.2.0) (2022-10-18) + +[Full Changelog](https://github.com/fastly/cli/compare/v4.1.0...v4.2.0) + +**Enhancements:** + +- Service Authorization [#660](https://github.com/fastly/cli/pull/660) +- Add Object Store API calls [#670](https://github.com/fastly/cli/pull/670) +- Remove upper limit on Go toolchain [#678](https://github.com/fastly/cli/pull/678) + +**Bug fixes:** + +- Fix `compute pack` to produce expected `package.tar.gz` filename [#662](https://github.com/fastly/cli/pull/662) +- Fix `--help` flag to not display an error [#672](https://github.com/fastly/cli/pull/672) +- Fix command substitution issue for Windows OS [#677](https://github.com/fastly/cli/pull/677) +- Fix Makefile for Windows [#679](https://github.com/fastly/cli/pull/679) + +## [v4.1.0](https://github.com/fastly/cli/releases/tag/v4.1.0) (2022-10-11) + +[Full Changelog](https://github.com/fastly/cli/compare/v4.0.1...v4.1.0) + +**Bug fixes:** + +- Fix Rust validation step for fastly crate dependency [#661](https://github.com/fastly/cli/pull/661) +- Fix `compute build --first-byte-timeout` [#667](https://github.com/fastly/cli/pull/667) +- Ensure the ./bin directory is present even with `--skip-verification` [#665](https://github.com/fastly/cli/pull/665) + +**Enhancements:** + +- Reduce duplication of strings in logging package [#653](https://github.com/fastly/cli/pull/653) +- Support `cert_host` and `use_sni` Viceroy properties [#663](https://github.com/fastly/cli/pull/663) + +## [v4.0.1](https://github.com/fastly/cli/releases/tag/v4.0.1) (2022-10-05) + +[Full Changelog](https://github.com/fastly/cli/compare/v4.0.0...v4.0.1) + +**Bug fixes:** + +- Fix JS dependency lookup [#656](https://github.com/fastly/cli/pull/656) +- Fix Rust 'existing project' bug [#657](https://github.com/fastly/cli/pull/657) +- Fix Rust toolchain lookup regression [#658](https://github.com/fastly/cli/pull/658) + +## [v4.0.0](https://github.com/fastly/cli/releases/tag/v4.0.0) (2022-10-04) + +[Full Changelog](https://github.com/fastly/cli/compare/v3.3.0...v4.0.0) + +**Enhancements:** + +- Bump go-fastly to v6.5.1 [#635](https://github.com/fastly/cli/pull/635) +- Update `--ssl-ciphers` description [#636](https://github.com/fastly/cli/pull/636) +- Improve JS error message when a dependency is missing [#637](https://github.com/fastly/cli/pull/637) +- Change default service version selection behaviour [#638](https://github.com/fastly/cli/pull/638) +- Support for additional S3 storage classes [#641](https://github.com/fastly/cli/pull/641) +- Change `compute serve --watch` flag to default to the project root directory [#642](https://github.com/fastly/cli/pull/642) +- Document the newly supported Datadog sites for logging [#576](https://github.com/fastly/cli/pull/576) +- Move the internal build scripts to the fastly.toml manifest [#640](https://github.com/fastly/cli/pull/640) +- Implement `compute hashsum` [#649](https://github.com/fastly/cli/pull/649) +- Add support for TOML `object_store` section [#651](https://github.com/fastly/cli/pull/651) +- Add `--account-name` to GCS logging endpoint [#549](https://github.com/fastly/cli/pull/549) + +**Bug fixes:** + +- errors/log: be defensive against nil pointer dereference [#650](https://github.com/fastly/cli/pull/650) + +**Documentation:** + +- Fix typos [#652](https://github.com/fastly/cli/pull/652) + +## [v3.3.0](https://github.com/fastly/cli/releases/tag/v3.3.0) (2022-09-05) + +[Full Changelog](https://github.com/fastly/cli/compare/v3.2.5...v3.3.0) + +**Enhancements:** + +- TLS Support [#630](https://github.com/fastly/cli/pull/630) +- CI to use community TinyGo action [#624](https://github.com/fastly/cli/pull/624) +- Improve compute init remediation [#627](https://github.com/fastly/cli/pull/627) +- Change default Makefile target [#629](https://github.com/fastly/cli/pull/629) +- Refactor after remote config removal [#626](https://github.com/fastly/cli/pull/626) +- Refactor for revive linter [#632](https://github.com/fastly/cli/pull/632) + +## [v3.2.5](https://github.com/fastly/cli/releases/tag/v3.2.5) (2022-08-10) + +[Full Changelog](https://github.com/fastly/cli/compare/v3.2.4...v3.2.5) + +**Enhancements:** + +- Remove dynamic configuration [#620](https://github.com/fastly/cli/pull/620) +- Static analysis updates [#621](https://github.com/fastly/cli/pull/621) +- Semgrep updates [#619](https://github.com/fastly/cli/pull/619) + +**Bug fixes:** + +- Fix `fastly help` tests to work with Go 1.19 [#623](https://github.com/fastly/cli/pull/623) + +## [v3.2.4](https://github.com/fastly/cli/releases/tag/v3.2.4) (2022-07-28) + +[Full Changelog](https://github.com/fastly/cli/compare/v3.2.3...v3.2.4) + +**Bug fixes:** + +- Fix `--completion-script-zsh` [#617](https://github.com/fastly/cli/pull/617) + +## [v3.2.3](https://github.com/fastly/cli/releases/tag/v3.2.3) (2022-07-28) + +[Full Changelog](https://github.com/fastly/cli/releases/tag/v3.2.2...v3.2.3) + +**Bug fixes:** + +- Block for config update if CLI version updated [#615](https://github.com/fastly/cli/pull/615) + +## [v3.2.2](https://github.com/fastly/cli/releases/tag/v3.2.2) (2022-07-28) + +[Full Changelog](https://github.com/fastly/cli/compare/v3.2.1...v3.2.2) + +**Bug fixes:** + +- Ignore TTL & update config if app version doesn't match config version [#612](https://github.com/fastly/cli/pull/612) + +## [v3.2.1](https://github.com/fastly/cli/releases/tag/v3.2.1) (2022-07-27) + +[Full Changelog](https://github.com/fastly/cli/compare/v3.2.0...v3.2.1) + +**Enhancements:** + +- Print subprocess commands in verbose mode [#608](https://github.com/fastly/cli/pull/608) + +**Bug fixes:** + +- Ensure application configuration is updated after `fastly update` [#610](https://github.com/fastly/cli/pull/610) +- Don't include language manifest in packages [#609](https://github.com/fastly/cli/pull/609) + +## [v3.2.0](https://github.com/fastly/cli/releases/tag/v3.2.0) (2022-07-27) + +[Full Changelog](https://github.com/fastly/cli/compare/v3.1.1...v3.2.0) + +**Enhancements:** + +- Compute@Edge TinyGo Support [#594](https://github.com/fastly/cli/pull/594) +- `pkg/commands/profile`: add `--json` flag for `list` command [#602](https://github.com/fastly/cli/pull/602) + +**Bug fixes:** + +- `pkg/commands/compute/deploy.go` (`getHashSum`): sort key order [#596](https://github.com/fastly/cli/pull/596) +- `pkg/errors/log.go`: prevent runtime panic due to a `nil` reference [#601](https://github.com/fastly/cli/pull/601) + +## [v3.1.1](https://github.com/fastly/cli/releases/tag/v3.1.1) (2022-07-04) + +[Full Changelog](https://github.com/fastly/cli/compare/v3.1.0...v3.1.1) + +**Enhancements:** + +- Handle build info more accurately [#588](https://github.com/fastly/cli/pull/588) + +**Bug fixes:** + +- Fix version check [#589](https://github.com/fastly/cli/pull/589) +- Address profile data loss [#590](https://github.com/fastly/cli/pull/590) + +## [v3.1.0](https://github.com/fastly/cli/releases/tag/v3.1.0) (2022-06-24) + +[Full Changelog](https://github.com/fastly/cli/compare/v3.0.1...v3.1.0) + +**Enhancements:** + +- Implement `profile token` command [#578](https://github.com/fastly/cli/pull/578) + +**Bug fixes:** + +- Fix `compute publish --non-interactive` [#583](https://github.com/fastly/cli/pull/583) +- Support `fastly --help ` format [#581](https://github.com/fastly/cli/pull/581) + +## [v3.0.1](https://github.com/fastly/cli/releases/tag/v3.0.1) (2022-06-23) + +[Full Changelog](https://github.com/fastly/cli/compare/v3.0.0...v3.0.1) + +**Enhancements:** + +- Makefile: when building binary, depend on .go files [#579](https://github.com/fastly/cli/pull/579) +- Include `fastly.toml` hashsum [#575](https://github.com/fastly/cli/pull/575) +- Hash main.wasm and not the package [#574](https://github.com/fastly/cli/pull/574) + +## [v3.0.0](https://github.com/fastly/cli/releases/tag/v3.0.0) (2022-05-30) + +[Full Changelog](https://github.com/fastly/cli/compare/v2.0.3...v3.0.0) + +**Breaking changes:** + +- Implement new global flags for handling interactive prompts [#568](https://github.com/fastly/cli/pull/568) + +**Bug fixes:** + +- The `backend create` command should set `--port` value if specified [#566](https://github.com/fastly/cli/pull/566) +- Don't overwrite `file.Load` error with `nil` [#569](https://github.com/fastly/cli/pull/569) + +**Enhancements:** + +- Support `[scripts.post_build]` [#565](https://github.com/fastly/cli/pull/565) +- Support Viceroy "inline-toml" `format` and new `contents` field [#567](https://github.com/fastly/cli/pull/567) +- Add example inline-toml dictionary to tests [#570](https://github.com/fastly/cli/pull/570) +- Avoid config update checks when handling 'completion' flags [#554](https://github.com/fastly/cli/pull/554) + +## [v2.0.3](https://github.com/fastly/cli/releases/tag/v2.0.3) (2022-05-20) + +[Full Changelog](https://github.com/fastly/cli/compare/v2.0.2...v2.0.3) + +**Bug fixes:** + +- Update Sentry DSN [#563](https://github.com/fastly/cli/pull/563) + +## [v2.0.2](https://github.com/fastly/cli/releases/tag/v2.0.2) (2022-05-18) + +[Full Changelog](https://github.com/fastly/cli/compare/v2.0.1...v2.0.2) + +**Enhancements:** + +- Remove user identifiable data [#561](https://github.com/fastly/cli/pull/561) + +## [v2.0.1](https://github.com/fastly/cli/releases/tag/v2.0.1) (2022-05-17) + +[Full Changelog](https://github.com/fastly/cli/compare/v2.0.0...v2.0.1) + +**Security:** + +- Omit data from Sentry [#559](https://github.com/fastly/cli/pull/559) + +## [v2.0.0](https://github.com/fastly/cli/releases/tag/v2.0.0) (2022-04-05) + +[Full Changelog](https://github.com/fastly/cli/compare/v1.7.1...v2.0.0) + +**Bug fixes:** + +- Update `--request-max-entries`/`--request-max-bytes` description defaults [#541](https://github.com/fastly/cli/pull/541) + +**Enhancements:** + +- Add `--debug` flag to `compute serve` [#545](https://github.com/fastly/cli/pull/545) +- Support multiple profiles via `[profiles]` configuration [#546](https://github.com/fastly/cli/pull/546) +- Reorder C@E languages and make JS 'Limited Availability' [#548](https://github.com/fastly/cli/pull/548) + +## [v1.7.1](https://github.com/fastly/cli/releases/tag/v1.7.1) (2022-03-14) + +[Full Changelog](https://github.com/fastly/cli/compare/v1.7.0...v1.7.1) + +**Bug fixes:** + +- Fix runtime panic in arg parser [#542](https://github.com/fastly/cli/pull/542) + +## [v1.7.0](https://github.com/fastly/cli/releases/tag/v1.7.0) (2022-02-22) + +[Full Changelog](https://github.com/fastly/cli/compare/v1.6.0...v1.7.0) + +**Enhancements:** + +- Add `fastly` user to Dockerfiles [#521](https://github.com/fastly/cli/pull/521) +- Support Sentry 'suspect commit' feature [#508](https://github.com/fastly/cli/pull/508) +- Populate language manifest `name` field with project name [#527](https://github.com/fastly/cli/pull/527) +- Make `--name` flag for `service search` command a required flag [#529](https://github.com/fastly/cli/pull/529) +- Update config `last_checked` field even on config load error [#528](https://github.com/fastly/cli/pull/528) +- Implement Compute@Edge Free Trial Activation [#531](https://github.com/fastly/cli/pull/531) +- Bump Rust toolchain constraint to `1.56.1` for 2021 edition [#533](https://github.com/fastly/cli/pull/533533533533533) +- Support Arch User Repositories [#530](https://github.com/fastly/cli/pull/530) + +## [v1.6.0](https://github.com/fastly/cli/releases/tag/v1.6.0) (2022-01-20) + +[Full Changelog](https://github.com/fastly/cli/compare/v1.5.0...v1.6.0) + +**Enhancements:** + +- Display the requested command in Sentry breadcrumb [#519](https://github.com/fastly/cli/pull/519) + +## [v1.5.0](https://github.com/fastly/cli/releases/tag/v1.5.0) (2022-01-20) + +[Full Changelog](https://github.com/fastly/cli/compare/v1.4.0...v1.5.0) + +**Enhancements:** + +- Implement `--json` output for describe/list operations [#505](https://github.com/fastly/cli/pull/505) +- Omit unix file permissions from error message [#507](https://github.com/fastly/cli/pull/507) +- Implement new go-fastly pagination types [#511](https://github.com/fastly/cli/pull/511) + +## [v1.4.0](https://github.com/fastly/cli/releases/tag/v1.4.0) (2022-01-07) + +[Full Changelog](https://github.com/fastly/cli/compare/v1.3.0...v1.4.0) + +**Enhancements:** + +- Add `viceroy.ttl` to CLI app config [#489](https://github.com/fastly/cli/pull/489) +- Display `viceroy --version` if installed [#487](https://github.com/fastly/cli/pull/487) +- Support `compute build` for 'other' language option using `[scripts.build]` [#484](https://github.com/fastly/cli/pull/484) +- Pass parent environment to subprocess [#491](https://github.com/fastly/cli/pull/491) +- Implement a yes/no user prompt abstraction [#500](https://github.com/fastly/cli/pull/500) +- Ensure build compilation errors are displayed [#492](https://github.com/fastly/cli/pull/492) +- Implement `--service-name` as swap-in replacement for `--service-id` [#495](https://github.com/fastly/cli/pull/495) +- Support `FASTLY_CUSTOMER_ID` environment variable [#494](https://github.com/fastly/cli/pull/494) +- Support `gotest` [#501](https://github.com/fastly/cli/pull/501) + +**Bug fixes:** + +- Fix the `--watch` flag for AssemblyScript [#493](https://github.com/fastly/cli/pull/493) +- Fix `compute init --from` for Windows [#490](https://github.com/fastly/cli/pull/490) +- Avoid triggering GitHub API rate limit when running Viceroy in CI [#488](https://github.com/fastly/cli/pull/488) +- Fix Windows ANSI escape code rendering [#503](https://github.com/fastly/cli/pull/503) +- Prevent runtime panic when global flag set with no command [#502](https://github.com/fastly/cli/pull/502) + +## [v1.3.0](https://github.com/fastly/cli/releases/tag/v1.3.0) (2021-12-01) + +[Full Changelog](https://github.com/fastly/cli/compare/v1.2.0...v1.3.0) + +**Enhancements:** + +- Implement custom `[scripts.build]` operation [#480](https://github.com/fastly/cli/pull/480) +- Move `manifest` package into top-level `pkg` directory [#478](https://github.com/fastly/cli/pull/478) +- Refactor AssemblyScript logic to call out to the JavaScript implementation [#479](https://github.com/fastly/cli/pull/479) + +## [v1.2.0](https://github.com/fastly/cli/releases/tag/v1.2.0) (2021-11-25) + +[Full Changelog](https://github.com/fastly/cli/compare/v1.1.1...v1.2.0) + +**Enhancements:** + +- Implement `SEE ALSO` section in help output [#472](https://github.com/fastly/cli/pull/472) +- Add command 'API' metadata [#473](https://github.com/fastly/cli/pull/473) + +## [v1.1.1](https://github.com/fastly/cli/releases/tag/v1.1.1) (2021-11-11) + +[Full Changelog](https://github.com/fastly/cli/compare/v1.1.0...v1.1.1) + +**Bug fixes:** + +- Avoid displaying a wildcard domain [#468](https://github.com/fastly/cli/pull/468) +- Set sensible defaults for host related flags on `backend create` [#469](https://github.com/fastly/cli/pull/469) +- Fix error extracting package files from `.tgz` archive [#470](https://github.com/fastly/cli/pull/470) + +## [v1.1.0](https://github.com/fastly/cli/releases/tag/v1.1.0) (2021-11-08) + +[Full Changelog](https://github.com/fastly/cli/compare/v1.0.1...v1.1.0) + +**Enhancements:** + +- Implement `--watch` flag for `compute serve` [#464](https://github.com/fastly/cli/pull/464) + +## [v1.0.1](https://github.com/fastly/cli/releases/tag/v1.0.1) (2021-11-08) + +[Full Changelog](https://github.com/fastly/cli/compare/v1.0.0...v1.0.1) + +**Bug fixes:** + +- Allow git repo to be used as a value at the starter kit prompt [#465](https://github.com/fastly/cli/pull/465) + +## [v1.0.0](https://github.com/fastly/cli/releases/tag/v1.0.0) (2021-11-02) + +[Full Changelog](https://github.com/fastly/cli/compare/v0.43.0...v1.0.0) + +**Changed:** + +- Use `EnumsVar` for `auth-token --scope` [#447](https://github.com/fastly/cli/pull/447) +- Rename `logs tail` to `log-tail` [#456](https://github.com/fastly/cli/pull/456) +- Rename `dictionaryitem` to `dictionary-item` [#459](https://github.com/fastly/cli/pull/459) +- Rename `fastly compute ... --path` flags [#460](https://github.com/fastly/cli/pull/460) +- Rename `--force` to `--skip-verification` [#461](https://github.com/fastly/cli/pull/461) + +## [v0.43.0](https://github.com/fastly/cli/releases/tag/v0.43.0) (2021-11-01) + +[Full Changelog](https://github.com/fastly/cli/compare/v0.42.0...v0.43.0) + +**Bug fixes:** + +- Ignore possible `rustup` 'sync' output when calling `rustc --version` [#453](https://github.com/fastly/cli/pull/453) +- Bump goreleaser to avoid Homebrew warning [#455](https://github.com/fastly/cli/pull/455) +- Prevent `.Hidden()` flags/commands from being documented via `--format json` [#454](https://github.com/fastly/cli/pull/454) + +## [v0.42.0](https://github.com/fastly/cli/releases/tag/v0.42.0) (2021-10-22) + +[Full Changelog](https://github.com/fastly/cli/compare/v0.41.0...v0.42.0) + +**Enhancements:** + +- Fallback to existing viceroy binary in case of network error [#445](https://github.com/fastly/cli/pull/445) +- Remove `text.ServiceType` abstraction [#449](https://github.com/fastly/cli/pull/449) + +**Bug fixes:** + +- Avoid fetching packages when manifest exists [#448](https://github.com/fastly/cli/pull/448) +- Implement `--path` lookup fallback for manifest [#446](https://github.com/fastly/cli/pull/446) +- Fix broken Windows support [#450](https://github.com/fastly/cli/pull/450) + +## [v0.41.0](https://github.com/fastly/cli/releases/tag/v0.41.0) (2021-10-19) + +[Full Changelog](https://github.com/fastly/cli/compare/v0.40.2...v0.41.0) + +**Enhancements:** + +- Implement `[setup.log_endpoints.]` support [#443](https://github.com/fastly/cli/pull/443) +- The `compute init --from` flag should support archives [#428](https://github.com/fastly/cli/pull/428) +- Add region support for logentries logging endpoint [#375](https://github.com/fastly/cli/pull/375) + +## [v0.40.2](https://github.com/fastly/cli/releases/tag/v0.40.2) (2021-10-14) + +[Full Changelog](https://github.com/fastly/cli/compare/v0.40.1...v0.40.2) + +**Bug fixes:** + +- Fix shell autocomplete evaluation [#441](https://github.com/fastly/cli/pull/441) + +## [v0.40.1](https://github.com/fastly/cli/releases/tag/v0.40.1) (2021-10-14) + +[Full Changelog](https://github.com/fastly/cli/compare/v0.40.0...v0.40.1) + +**Bug fixes:** + +- Fix shell completion (and Homebrew upgrade) [#439](https://github.com/fastly/cli/pull/439) + +## [v0.40.0](https://github.com/fastly/cli/releases/tag/v0.40.0) (2021-10-13) + +[Full Changelog](https://github.com/fastly/cli/compare/v0.39.3...v0.40.0) + +**Bug fixes:** + +- Auto-migrate `manifest_version` from 1 to 2 when applicable [#434](https://github.com/fastly/cli/pull/434) +- Better error handling for manifest parsing [#436](https://github.com/fastly/cli/pull/436) + +**Enhancements:** + +- Implement `[setup.dictionaries]` support [#431](https://github.com/fastly/cli/pull/431) +- Tests for `[setup.dictionaries]` support [#438](https://github.com/fastly/cli/pull/438) +- Refactor `app.Run()` [#429](https://github.com/fastly/cli/pull/429) +- Ensure manifest is read only once for all missed references [#433](https://github.com/fastly/cli/pull/433) + +## [v0.39.3](https://github.com/fastly/cli/releases/tag/v0.39.3) (2021-10-06) + +[Full Changelog](https://github.com/fastly/cli/compare/v0.39.2...v0.39.3) + +**Bug fixes:** + +- Add missing description for `user list --customer-id` [#425](https://github.com/fastly/cli/pull/425) +- Trim the rust version to fix parse errors [#427](https://github.com/fastly/cli/pull/427) + +**Enhancements:** + +- Abstraction layer for `[setup.backends]` [#421](https://github.com/fastly/cli/pull/421) + +## [v0.39.2](https://github.com/fastly/cli/releases/tag/v0.39.2) (2021-09-29) + +[Full Changelog](https://github.com/fastly/cli/compare/v0.39.1...v0.39.2) + +**Bug fixes:** + +- Provide better remediation for unrecognised `manifest_version` [#422](https://github.com/fastly/cli/pull/422) +- Bump `go-fastly` to `v5.0.0` to fix ACL entries bug [#417](https://github.com/fastly/cli/pull/417) +- Remove Rust debug flags [#416](https://github.com/fastly/cli/pull/416) + +**Enhancements:** + +- Clarify Starter Kit options in `compute init` flow [#418](https://github.com/fastly/cli/pull/418) +- Avoid excessive manifest reads [#420](https://github.com/fastly/cli/pull/420) + +## [v0.39.1](https://github.com/fastly/cli/releases/tag/v0.39.1) (2021-09-21) + +[Full Changelog](https://github.com/fastly/cli/compare/v0.39.0...v0.39.1) + +**Bug fixes:** + +- Bug fixes for `auth-token` [#413](https://github.com/fastly/cli/pull/413) + +## [v0.39.0](https://github.com/fastly/cli/releases/tag/v0.39.0) (2021-09-21) + +[Full Changelog](https://github.com/fastly/cli/compare/v0.38.0...v0.39.0) + +**Enhancements:** + +- Implement `user` commands [#406](https://github.com/fastly/cli/pull/406) +- Implement `auth-token` commands [#409](https://github.com/fastly/cli/pull/409) +- Add region support for New Relic logging endpoint [#378](https://github.com/fastly/cli/pull/378) + +**Bug fixes:** + +- Add the `--name` flag to `compute deploy` [#410](https://github.com/fastly/cli/pull/410) + +## [v0.38.0](https://github.com/fastly/cli/releases/tag/v0.38.0) (2021-09-15) + +[Full Changelog](https://github.com/fastly/cli/compare/v0.37.1...v0.38.0) + +**Enhancements:** + +- Add support for `override_host` to Local Server configuration [#394](https://github.com/fastly/cli/pull/394) +- Add support for Dictionaries to Local Server configuration [#395](https://github.com/fastly/cli/pull/395) +- Integrate domain validation [#402](https://github.com/fastly/cli/pull/402) +- Refactor Versioner `GitHub.Download()` logic [#403](https://github.com/fastly/cli/pull/403) + +**Bug fixes:** + +- Pass down `compute publish --name` to `compute deploy` [#398](https://github.com/fastly/cli/pull/398) +- Sanitise name when packing the wasm file [#401](https://github.com/fastly/cli/pull/401) +- Use a non-interactive progress writer in non-TTY environments [#405](https://github.com/fastly/cli/pull/405) + +**Removed:** + +- Remove support for Scoop, the Window's command-line installer [#396](https://github.com/fastly/cli/pull/396) +- Remove unused 'rename local binary' code [#399](https://github.com/fastly/cli/pull/399) + +## [v0.37.1](https://github.com/fastly/cli/releases/tag/v0.37.1) (2021-09-06) + +[Full Changelog](https://github.com/fastly/cli/compare/v0.37.0...v0.37.1) + +**Enhancements:** + +- Bump `go-github` dependency to latest release [#388](https://github.com/fastly/cli/pull/388) +- Add Service ID to `--verbose` output [#383](https://github.com/fastly/cli/pull/383) + +**Bug fixes:** + +- Download Viceroy to a _randomly_ generated directory [#386](https://github.com/fastly/cli/pull/386) +- Bug fix for ensuring assets are downloaded into a randomly generated directory [#389](https://github.com/fastly/cli/pull/389) + +## [v0.37.0](https://github.com/fastly/cli/releases/tag/v0.37.0) (2021-09-03) + +[Full Changelog](https://github.com/fastly/cli/compare/v0.36.0...v0.37.0) + +**Enhancements:** + +- Update CLI config using flag on `update` command [#382](https://github.com/fastly/cli/pull/382) +- Validate package size doesn't exceed limit [#380](https://github.com/fastly/cli/pull/380) +- Log tailing should respect the configured `--endpoint` [#374](https://github.com/fastly/cli/pull/374) +- Support Windows arm64 [#372](https://github.com/fastly/cli/pull/372) +- Refactor compute deploy logic to better support `[setup]` configuration [#370](https://github.com/fastly/cli/pull/370) +- Omit messaging when using `--accept-defaults` [#366](https://github.com/fastly/cli/pull/366) +- Implement `[setup]` configuration for backends [#355](https://github.com/fastly/cli/pull/355) +- Refactor code to help CI performance [#360](https://github.com/fastly/cli/pull/360) + +**Bug fixes:** + +- Add executable permissions to Viceroy binary after renaming/moving it [#368](https://github.com/fastly/cli/pull/368) +- Update rust toolchain validation steps [#371](https://github.com/fastly/cli/pull/371) + +**Security:** + +- Update dependency to avoid dependabot warning in GitHub UI [#381](https://github.com/fastly/cli/pull/381) + +## [v0.36.0](https://github.com/fastly/cli/releases/tag/v0.36.0) (2021-07-30) + +[Full Changelog](https://github.com/fastly/cli/compare/v0.35.0...v0.36.0) + +**Enhancements:** + +- Implement `logging newrelic` command [#354](https://github.com/fastly/cli/pull/354) + +## [v0.35.0](https://github.com/fastly/cli/releases/tag/v0.35.0) (2021-07-29) + +[Full Changelog](https://github.com/fastly/cli/compare/v0.34.0...v0.35.0) + +**Enhancements:** + +- Support for Compute@Edge JS SDK (Beta) [#347](https://github.com/fastly/cli/pull/347) +- Implement `--override-host` and `--ssl-sni-hostname` [#352](https://github.com/fastly/cli/pull/352) +- Implement `acl` command [#350](https://github.com/fastly/cli/pull/350) +- Implement `acl-entry` command [#351](https://github.com/fastly/cli/pull/351) +- Separate command files from other logic files [#349](https://github.com/fastly/cli/pull/349) +- Log a record of errors to disk [#340](https://github.com/fastly/cli/pull/340) + +**Bug fixes:** + +- Fix nondeterministic flag parsing [#353](https://github.com/fastly/cli/pull/353) +- Fix `compute serve --addr` description to reference port requirement [#348](https://github.com/fastly/cli/pull/348) + +## [v0.34.0](https://github.com/fastly/cli/releases/tag/v0.34.0) (2021-07-16) + +[Full Changelog](https://github.com/fastly/cli/compare/v0.33.0...v0.34.0) + +**Enhancements:** + +- Implement `compute serve` subcommand [#252](https://github.com/fastly/cli/pull/252) +- Simplify logic for prefixing fastly spec to file [#345](https://github.com/fastly/cli/pull/345) +- `fastly compute publish` and `deploy` should accept a comment [#328](https://github.com/fastly/cli/pull/328) +- Improve GitHub Actions intermittent test timeouts [#336](https://github.com/fastly/cli/pull/336) +- New flags for displaying the CLI config, and its location [#338](https://github.com/fastly/cli/pull/338) +- Don't allow stats short help to wrap [#331](https://github.com/fastly/cli/pull/331) + +**Bug fixes:** + +- Ensure incompatibility message only shown when config is invalid [#335](https://github.com/fastly/cli/pull/335) +- Check-in static config for traditional golang workflows [#337](https://github.com/fastly/cli/pull/337) + +## [v0.33.0](https://github.com/fastly/cli/releases/tag/v0.33.0) (2021-07-06) + +[Full Changelog](https://github.com/fastly/cli/compare/v0.32.0...v0.33.0) + +**Enhancements:** + +- Improve CI workflow [#333](https://github.com/fastly/cli/pull/333) +- Support multiple versions of Rust [#330](https://github.com/fastly/cli/pull/330) +- Replace `app.Run` positional signature with a struct [#329](https://github.com/fastly/cli/pull/329) +- Test suite improvements [#327](https://github.com/fastly/cli/pull/327) + +## [v0.32.0](https://github.com/fastly/cli/releases/tag/v0.32.0) (2021-06-30) + +[Full Changelog](https://github.com/fastly/cli/compare/v0.31.0...v0.32.0) + +**Enhancements:** + +- Embed app config into compiled CLI binary [#312](https://github.com/fastly/cli/pull/312) +- Service ID lookup includes `$FASTLY_SERVICE_ID` environment variable [#320](https://github.com/fastly/cli/pull/320) +- Implement `vcl custom` commands [#310](https://github.com/fastly/cli/pull/310) +- Implement `vcl snippet` commands [#316](https://github.com/fastly/cli/pull/316) +- Implement `purge` command [#323](https://github.com/fastly/cli/pull/323) + +**Bug fixes:** + +- Correctly set the port if `--use-ssl` is used [#317](https://github.com/fastly/cli/pull/317) +- Fixed a regression in `compute publish` [#321](https://github.com/fastly/cli/pull/321) + +## [v0.31.0](https://github.com/fastly/cli/releases/tag/v0.31.0) (2021-06-17) + +[Full Changelog](https://github.com/fastly/cli/compare/v0.30.0...v0.31.0) + +**Enhancements:** + +- Add new `pops` command [#309](https://github.com/fastly/cli/pull/309) +- Add new `ip-list` command [#308](https://github.com/fastly/cli/pull/308) +- Implement new `--version` and `--autoclone` flags [#302](https://github.com/fastly/cli/pull/302) +- Reword `backend create --use-ssl` warning output [#303](https://github.com/fastly/cli/pull/303) +- Define new `--version` and `--autoclone` flags [#300](https://github.com/fastly/cli/pull/300) +- Implement remediation for dynamic config context deadline error [#298](https://github.com/fastly/cli/pull/298) +- Capitalise 'n' for `[y/N]` prompt [#299](https://github.com/fastly/cli/pull/299) +- Move exec behaviour from `common` package to its own package [#297](https://github.com/fastly/cli/pull/297) +- Move command behaviour from `common` package to its own package [#296](https://github.com/fastly/cli/pull/296) +- Move time behaviour from `common` package to its own package [#295](https://github.com/fastly/cli/pull/295) +- Move sync behaviour from `common` package to its own package [#294](https://github.com/fastly/cli/pull/294) +- Move undo behaviour from `common` package to its own package [#293](https://github.com/fastly/cli/pull/293) +- Surface any cargo metadata errors [#286](https://github.com/fastly/cli/pull/286) + +**Bug fixes:** + +- Don't persist `--service-id` flag value to manifest [#307](https://github.com/fastly/cli/pull/307) +- Fix broken `--service-id` flag in `compute publish` [#292](https://github.com/fastly/cli/pull/292) +- Fix parsing backend port number [#291](https://github.com/fastly/cli/pull/291) + +**Documentation:** + +- Update broken link in `stats historical` [#285](https://github.com/fastly/cli/pull/285) + +## [v0.30.0](https://github.com/fastly/cli/releases/tag/v0.30.0) (2021-05-19) + +[Full Changelog](https://github.com/fastly/cli/compare/v0.29.0...v0.30.0) + +**Enhancements:** + +- Update messaging for `rustup self update` [#281](https://github.com/fastly/cli/pull/281) +- Replace archived go-git dependency [#283](https://github.com/fastly/cli/pull/283) +- Implement `pack` subcommand [#282](https://github.com/fastly/cli/pull/282) + +## [v0.29.0](https://github.com/fastly/cli/releases/tag/v0.29.0) (2021-05-13) + +[Full Changelog](https://github.com/fastly/cli/compare/v0.28.0...v0.29.0) + +**Enhancements:** + +- Add arm64 to macOS build [#277](https://github.com/fastly/cli/pull/277) + +**Bug fixes:** + +- Validate package before prompting inside `compute deploy` flow [#279](https://github.com/fastly/cli/pull/279) +- Clear Service ID from manifest when service is deleted [#278](https://github.com/fastly/cli/pull/278) ## [v0.28.0](https://github.com/fastly/cli/releases/tag/v0.28.0) (2021-05-11) @@ -6,14 +2007,14 @@ **Enhancements:** -- Add `isBool` to command flags [\#267](https://github.com/fastly/cli/pull/267) -- Move service creation to `fastly compute deploy`. [\#266](https://github.com/fastly/cli/pull/266) +- Add `isBool` to command flags [#267](https://github.com/fastly/cli/pull/267) +- Move service creation to `fastly compute deploy`. [#266](https://github.com/fastly/cli/pull/266) **Bug fixes:** -- Fix runtime panic when dealing with empty manifest. [\#274](https://github.com/fastly/cli/pull/274) -- Fix `--force` flag not being respected. [\#272](https://github.com/fastly/cli/pull/272) -- Clean-out `service_id` from manifest when deleting a service. [\#268](https://github.com/fastly/cli/pull/268) +- Fix runtime panic when dealing with empty manifest. [#274](https://github.com/fastly/cli/pull/274) +- Fix `--force` flag not being respected. [#272](https://github.com/fastly/cli/pull/272) +- Clean-out `service_id` from manifest when deleting a service. [#268](https://github.com/fastly/cli/pull/268) ## [v0.27.2](https://github.com/fastly/cli/releases/tag/v0.27.2) (2021-04-21) @@ -21,7 +2022,7 @@ **Bug fixes:** -- Fix bug where legacy creds are reset after call to configure subcommand. [\#260](https://github.com/fastly/cli/pull/260) +- Fix bug where legacy creds are reset after call to configure subcommand. [#260](https://github.com/fastly/cli/pull/260) ## [v0.27.1](https://github.com/fastly/cli/releases/tag/v0.27.1) (2021-04-16) @@ -29,7 +2030,7 @@ **Bug fixes:** -- Track CLI version. [\#257](https://github.com/fastly/cli/pull/257) +- Track CLI version. [#257](https://github.com/fastly/cli/pull/257) ## [v0.27.0](https://github.com/fastly/cli/releases/tag/v0.27.0) (2021-04-15) @@ -37,22 +2038,22 @@ **Enhancements:** -- Support IAM role in Kinesis logging endpoint [\#255](https://github.com/fastly/cli/pull/255) -- Support IAM role in S3 and Kinesis logging endpoints [\#253](https://github.com/fastly/cli/pull/253) -- Add support for `file_max_bytes` configuration for Azure logging endpoint [\#251](https://github.com/fastly/cli/pull/251) -- Warn on empty directory [\#247](https://github.com/fastly/cli/pull/247) -- Add `compute publish` subcommand [\#242](https://github.com/fastly/cli/pull/242) -- Allow local binary to be renamed [\#240](https://github.com/fastly/cli/pull/240) -- Retain `RUSTFLAGS` values from the environment [\#239](https://github.com/fastly/cli/pull/239) -- Make GitHub Versioner configurable [\#236](https://github.com/fastly/cli/pull/236) -- Add support for `compression_codec` to logging file sink endpoints [\#190](https://github.com/fastly/cli/pull/190) +- Support IAM role in Kinesis logging endpoint [#255](https://github.com/fastly/cli/pull/255) +- Support IAM role in S3 and Kinesis logging endpoints [#253](https://github.com/fastly/cli/pull/253) +- Add support for `file_max_bytes` configuration for Azure logging endpoint [#251](https://github.com/fastly/cli/pull/251) +- Warn on empty directory [#247](https://github.com/fastly/cli/pull/247) +- Add `compute publish` subcommand [#242](https://github.com/fastly/cli/pull/242) +- Allow local binary to be renamed [#240](https://github.com/fastly/cli/pull/240) +- Retain `RUSTFLAGS` values from the environment [#239](https://github.com/fastly/cli/pull/239) +- Make GitHub Versioner configurable [#236](https://github.com/fastly/cli/pull/236) +- Add support for `compression_codec` to logging file sink endpoints [#190](https://github.com/fastly/cli/pull/190) **Bug fixes:** -- Remove flaky test logic. [\#249](https://github.com/fastly/cli/pull/249) -- Check the rustup version [\#248](https://github.com/fastly/cli/pull/248) -- Print all commands and subcommands in usage [\#244](https://github.com/fastly/cli/pull/244) -- pkg/logs: fix typo in error message [\#238](https://github.com/fastly/cli/pull/238) +- Remove flaky test logic. [#249](https://github.com/fastly/cli/pull/249) +- Check the rustup version [#248](https://github.com/fastly/cli/pull/248) +- Print all commands and subcommands in usage [#244](https://github.com/fastly/cli/pull/244) +- pkg/logs: fix typo in error message [#238](https://github.com/fastly/cli/pull/238) ## [v0.26.3](https://github.com/fastly/cli/releases/tag/v0.26.3) (2021-03-26) @@ -60,12 +2061,12 @@ **Enhancements:** -- Default to port 443 if UseSSL set. [\#234](https://github.com/fastly/cli/pull/234) +- Default to port 443 if UseSSL set. [#234](https://github.com/fastly/cli/pull/234) **Bug fixes:** -- Ensure all UPDATE operations don't set optional fields. [\#235](https://github.com/fastly/cli/pull/235) -- Avoid setting fields that cause API to fail when given zero value. [\#233](https://github.com/fastly/cli/pull/233) +- Ensure all UPDATE operations don't set optional fields. [#235](https://github.com/fastly/cli/pull/235) +- Avoid setting fields that cause API to fail when given zero value. [#233](https://github.com/fastly/cli/pull/233) ## [v0.26.2](https://github.com/fastly/cli/releases/tag/v0.26.2) (2021-03-22) @@ -73,12 +2074,12 @@ **Enhancements:** -- Extra error handling around loading remote configuration data. [\#229](https://github.com/fastly/cli/pull/229) +- Extra error handling around loading remote configuration data. [#229](https://github.com/fastly/cli/pull/229) **Bug fixes:** -- `fastly compute build` exits with error 1 [\#227](https://github.com/fastly/cli/issues/227) -- Set GOVERSION for goreleaser. [\#228](https://github.com/fastly/cli/pull/228) +- `fastly compute build` exits with error 1 [#227](https://github.com/fastly/cli/issues/227) +- Set GOVERSION for goreleaser. [#228](https://github.com/fastly/cli/pull/228) ## [v0.26.1](https://github.com/fastly/cli/releases/tag/v0.26.1) (2021-03-19) @@ -86,7 +2087,7 @@ **Bug fixes:** -- Fix manifest\_version as a section bug. [\#225](https://github.com/fastly/cli/pull/225) +- Fix manifest_version as a section bug. [#225](https://github.com/fastly/cli/pull/225) ## [v0.26.0](https://github.com/fastly/cli/releases/tag/v0.26.0) (2021-03-18) @@ -94,14 +2095,14 @@ **Enhancements:** -- Remove version from fastly.toml manifest. [\#222](https://github.com/fastly/cli/pull/222) -- Don't run "cargo update" before building rust app. [\#221](https://github.com/fastly/cli/pull/221) +- Remove version from fastly.toml manifest. [#222](https://github.com/fastly/cli/pull/222) +- Don't run "cargo update" before building rust app. [#221](https://github.com/fastly/cli/pull/221) **Bug fixes:** -- Loading remote config.toml should fail gracefully. [\#223](https://github.com/fastly/cli/pull/223) -- Update the fastly.toml manifest if missing manifest\_version. [\#220](https://github.com/fastly/cli/pull/220) -- Refactor UserAgent. [\#219](https://github.com/fastly/cli/pull/219) +- Loading remote config.toml should fail gracefully. [#223](https://github.com/fastly/cli/pull/223) +- Update the fastly.toml manifest if missing manifest_version. [#220](https://github.com/fastly/cli/pull/220) +- Refactor UserAgent. [#219](https://github.com/fastly/cli/pull/219) ## [v0.25.2](https://github.com/fastly/cli/releases/tag/v0.25.2) (2021-03-16) @@ -109,7 +2110,7 @@ **Bug fixes:** -- Fix duplicate warning messages and missing SetOutput\(\). [\#216](https://github.com/fastly/cli/pull/216) +- Fix duplicate warning messages and missing SetOutput(). [#216](https://github.com/fastly/cli/pull/216) ## [v0.25.1](https://github.com/fastly/cli/releases/tag/v0.25.1) (2021-03-16) @@ -117,7 +2118,7 @@ **Bug fixes:** -- The manifest\_version should default to 1 if missing. [\#214](https://github.com/fastly/cli/pull/214) +- The manifest_version should default to 1 if missing. [#214](https://github.com/fastly/cli/pull/214) ## [v0.25.0](https://github.com/fastly/cli/releases/tag/v0.25.0) (2021-03-16) @@ -125,15 +2126,15 @@ **Enhancements:** -- Replace deprecated ioutil functions with go 1.16. [\#212](https://github.com/fastly/cli/pull/212) -- Replace TOML parser [\#211](https://github.com/fastly/cli/pull/211) -- Implement manifest\_version into the fastly.toml [\#210](https://github.com/fastly/cli/pull/210) -- Dynamic Configuration [\#187](https://github.com/fastly/cli/pull/187) +- Replace deprecated ioutil functions with go 1.16. [#212](https://github.com/fastly/cli/pull/212) +- Replace TOML parser [#211](https://github.com/fastly/cli/pull/211) +- Implement manifest_version into the fastly.toml [#210](https://github.com/fastly/cli/pull/210) +- Dynamic Configuration [#187](https://github.com/fastly/cli/pull/187) **Bug fixes:** -- Log output should be simplified when running in CI [\#175](https://github.com/fastly/cli/issues/175) -- Override error message in compute init [\#204](https://github.com/fastly/cli/pull/204) +- Log output should be simplified when running in CI [#175](https://github.com/fastly/cli/issues/175) +- Override error message in compute init [#204](https://github.com/fastly/cli/pull/204) ## [v0.24.2](https://github.com/fastly/cli/releases/tag/v0.24.2) (2021-02-15) @@ -141,12 +2142,12 @@ **Bug fixes:** -- Fix CI binary overlap [\#209](https://github.com/fastly/cli/pull/209) -- Fix CI workflow by switching from old syntax to new [\#208](https://github.com/fastly/cli/pull/208) -- Fix goreleaser version lookup [\#207](https://github.com/fastly/cli/pull/207) -- LogTail: Properly close response body [\#205](https://github.com/fastly/cli/pull/205) -- Add port prompt for compute init [\#203](https://github.com/fastly/cli/pull/203) -- Update GitHub Action to not use commit hash [\#200](https://github.com/fastly/cli/pull/200) +- Fix CI binary overlap [#209](https://github.com/fastly/cli/pull/209) +- Fix CI workflow by switching from old syntax to new [#208](https://github.com/fastly/cli/pull/208) +- Fix goreleaser version lookup [#207](https://github.com/fastly/cli/pull/207) +- LogTail: Properly close response body [#205](https://github.com/fastly/cli/pull/205) +- Add port prompt for compute init [#203](https://github.com/fastly/cli/pull/203) +- Update GitHub Action to not use commit hash [#200](https://github.com/fastly/cli/pull/200) ## [v0.24.1](https://github.com/fastly/cli/releases/tag/v0.24.1) (2021-02-03) @@ -154,7 +2155,7 @@ **Bug fixes:** -- Logs Tail: Give the user better feedback when --from flag errors [\#201](https://github.com/fastly/cli/pull/201) +- Logs Tail: Give the user better feedback when --from flag errors [#201](https://github.com/fastly/cli/pull/201) ## [v0.24.0](https://github.com/fastly/cli/releases/tag/v0.24.0) (2021-02-02) @@ -162,12 +2163,12 @@ **Enhancements:** -- Add static content starter kit [\#197](https://github.com/fastly/cli/pull/197) -- 🦀 Update rust toolchain [\#196](https://github.com/fastly/cli/pull/196) +- Add static content starter kit [#197](https://github.com/fastly/cli/pull/197) +- 🦀 Update rust toolchain [#196](https://github.com/fastly/cli/pull/196) **Bug fixes:** -- Fix go vet error related to missing docstring [\#198](https://github.com/fastly/cli/pull/198) +- Fix go vet error related to missing docstring [#198](https://github.com/fastly/cli/pull/198) ## [v0.23.0](https://github.com/fastly/cli/releases/tag/v0.23.0) (2021-01-22) @@ -175,13 +2176,13 @@ **Enhancements:** -- Update Go-Fastly dependency to v3.0.0 [\#193](https://github.com/fastly/cli/pull/193) -- Support for Compute@Edge Log Tailing [\#192](https://github.com/fastly/cli/pull/192) +- Update Go-Fastly dependency to v3.0.0 [#193](https://github.com/fastly/cli/pull/193) +- Support for Compute@Edge Log Tailing [#192](https://github.com/fastly/cli/pull/192) **Bug fixes:** -- Resolve issues with Rust integration tests. [\#194](https://github.com/fastly/cli/pull/194) -- Update URL for default Rust starter [\#191](https://github.com/fastly/cli/pull/191) +- Resolve issues with Rust integration tests. [#194](https://github.com/fastly/cli/pull/194) +- Update URL for default Rust starter [#191](https://github.com/fastly/cli/pull/191) ## [v0.22.0](https://github.com/fastly/cli/releases/tag/v0.22.0) (2021-01-07) @@ -189,8 +2190,8 @@ **Enhancements:** -- Add support for TLS client and batch size options for splunk [\#183](https://github.com/fastly/cli/pull/183) -- Add support for Kinesis logging endpoint [\#177](https://github.com/fastly/cli/pull/177) +- Add support for TLS client and batch size options for splunk [#183](https://github.com/fastly/cli/pull/183) +- Add support for Kinesis logging endpoint [#177](https://github.com/fastly/cli/pull/177) ## [v0.21.2](https://github.com/fastly/cli/releases/tag/v0.21.2) (2021-01-06) @@ -198,7 +2199,7 @@ **Bug fixes:** -- Switch from third-party dependency to our own mirror [\#184](https://github.com/fastly/cli/pull/184) +- Switch from third-party dependency to our own mirror [#184](https://github.com/fastly/cli/pull/184) ## [v0.21.1](https://github.com/fastly/cli/releases/tag/v0.21.1) (2020-12-18) @@ -206,8 +2207,8 @@ **Bug fixes:** -- CLI shouldn't recommend Rust crate prerelease versions [\#168](https://github.com/fastly/cli/issues/168) -- Run cargo update before attempting to build Rust compute packages [\#179](https://github.com/fastly/cli/pull/179) +- CLI shouldn't recommend Rust crate prerelease versions [#168](https://github.com/fastly/cli/issues/168) +- Run cargo update before attempting to build Rust compute packages [#179](https://github.com/fastly/cli/pull/179) ## [v0.21.0](https://github.com/fastly/cli/releases/tag/v0.21.0) (2020-12-14) @@ -215,7 +2216,7 @@ **Enhancements:** -- Adds support for managing edge dictionaries [\#159](https://github.com/fastly/cli/pull/159) +- Adds support for managing edge dictionaries [#159](https://github.com/fastly/cli/pull/159) ## [v0.20.0](https://github.com/fastly/cli/releases/tag/v0.20.0) (2020-11-24) @@ -223,12 +2224,12 @@ **Enhancements:** -- Migrate to Go-Fastly 2.0.0 [\#169](https://github.com/fastly/cli/pull/169) +- Migrate to Go-Fastly 2.0.0 [#169](https://github.com/fastly/cli/pull/169) **Bug fixes:** -- Build failure with Cargo workspaces [\#171](https://github.com/fastly/cli/issues/171) -- Support cargo workspaces [\#172](https://github.com/fastly/cli/pull/172) +- Build failure with Cargo workspaces [#171](https://github.com/fastly/cli/issues/171) +- Support cargo workspaces [#172](https://github.com/fastly/cli/pull/172) ## [v0.19.0](https://github.com/fastly/cli/releases/tag/v0.19.0) (2020-11-19) @@ -236,7 +2237,7 @@ **Enhancements:** -- Support sasl kafka endpoint options in Fastly CLI [\#161](https://github.com/fastly/cli/pull/161) +- Support sasl kafka endpoint options in Fastly CLI [#161](https://github.com/fastly/cli/pull/161) ## [v0.18.1](https://github.com/fastly/cli/releases/tag/v0.18.1) (2020-11-03) @@ -244,12 +2245,12 @@ **Enhancements:** -- Update the default Rust template to fastly-0.5.0 [\#163](https://github.com/fastly/cli/pull/163) +- Update the default Rust template to fastly-0.5.0 [#163](https://github.com/fastly/cli/pull/163) **Bug fixes:** -- Constrain Version Upgrade Suggestion [\#165](https://github.com/fastly/cli/pull/165) -- Fix AssemblyScript compilation messaging [\#164](https://github.com/fastly/cli/pull/164) +- Constrain Version Upgrade Suggestion [#165](https://github.com/fastly/cli/pull/165) +- Fix AssemblyScript compilation messaging [#164](https://github.com/fastly/cli/pull/164) ## [v0.18.0](https://github.com/fastly/cli/releases/tag/v0.18.0) (2020-10-27) @@ -257,7 +2258,7 @@ **Enhancements:** -- Add AssemblyScript support to compute init and build commands [\#160](https://github.com/fastly/cli/pull/160) +- Add AssemblyScript support to compute init and build commands [#160](https://github.com/fastly/cli/pull/160) ## [v0.17.0](https://github.com/fastly/cli/releases/tag/v0.17.0) (2020-09-24) @@ -265,12 +2266,12 @@ **Enhancements:** -- Bump supported Rust toolchain version to 1.46 [\#156](https://github.com/fastly/cli/pull/156) -- Add service search command [\#152](https://github.com/fastly/cli/pull/152) +- Bump supported Rust toolchain version to 1.46 [#156](https://github.com/fastly/cli/pull/156) +- Add service search command [#152](https://github.com/fastly/cli/pull/152) **Bug fixes:** -- Broken link in usage info [\#148](https://github.com/fastly/cli/issues/148) +- Broken link in usage info [#148](https://github.com/fastly/cli/issues/148) ## [v0.16.1](https://github.com/fastly/cli/releases/tag/v0.16.1) (2020-07-21) @@ -278,8 +2279,8 @@ **Bug fixes:** -- Display the correct version number on error [\#144](https://github.com/fastly/cli/pull/144) -- Fix bug where name was not added to the manifest [\#143](https://github.com/fastly/cli/pull/143) +- Display the correct version number on error [#144](https://github.com/fastly/cli/pull/144) +- Fix bug where name was not added to the manifest [#143](https://github.com/fastly/cli/pull/143) ## [v0.16.0](https://github.com/fastly/cli/releases/tag/v0.16.0) (2020-07-09) @@ -287,8 +2288,8 @@ **Enhancements:** -- Compare package hashsum during deployment [\#139](https://github.com/fastly/cli/pull/139) -- Allow compute init to be reinvoked within an existing package directory [\#138](https://github.com/fastly/cli/pull/138) +- Compare package hashsum during deployment [#139](https://github.com/fastly/cli/pull/139) +- Allow compute init to be reinvoked within an existing package directory [#138](https://github.com/fastly/cli/pull/138) ## [v0.15.0](https://github.com/fastly/cli/releases/tag/v0.15.0) (2020-06-29) @@ -296,7 +2297,7 @@ **Enhancements:** -- Adds OpenStack logging support [\#132](https://github.com/fastly/cli/pull/132) +- Adds OpenStack logging support [#132](https://github.com/fastly/cli/pull/132) ## [v0.14.0](https://github.com/fastly/cli/releases/tag/v0.14.0) (2020-06-25) @@ -304,7 +2305,7 @@ **Enhancements:** -- Bump default Rust template version to v0.4.0 [\#133](https://github.com/fastly/cli/pull/133) +- Bump default Rust template version to v0.4.0 [#133](https://github.com/fastly/cli/pull/133) ## [v0.13.0](https://github.com/fastly/cli/releases/tag/v0.13.0) (2020-06-15) @@ -312,15 +2313,15 @@ **Enhancements:** -- Allow compute services to be initialised from an existing service ID [\#125](https://github.com/fastly/cli/pull/125) +- Allow compute services to be initialised from an existing service ID [#125](https://github.com/fastly/cli/pull/125) **Bug fixes:** -- Fix bash completion [\#128](https://github.com/fastly/cli/pull/128) +- Fix bash completion [#128](https://github.com/fastly/cli/pull/128) **Closed issues:** -- Bash Autocomplete is broken [\#127](https://github.com/fastly/cli/issues/127) +- Bash Autocomplete is broken [#127](https://github.com/fastly/cli/issues/127) ## [v0.12.0](https://github.com/fastly/cli/releases/tag/v0.12.0) (2020-06-05) @@ -328,16 +2329,16 @@ **Enhancements:** -- Adds MessageType field to SFTP [\#118](https://github.com/fastly/cli/pull/118) -- Adds User field to Cloudfiles Updates [\#117](https://github.com/fastly/cli/pull/117) -- Adds Region field to Scalyr [\#116](https://github.com/fastly/cli/pull/116) -- Adds PublicKey field to S3 [\#114](https://github.com/fastly/cli/pull/114) -- Adds MessageType field to GCS Updates [\#113](https://github.com/fastly/cli/pull/113) -- Adds ResponseCondition and Placement fields to BigQuery Creates [\#111](https://github.com/fastly/cli/pull/111) +- Adds MessageType field to SFTP [#118](https://github.com/fastly/cli/pull/118) +- Adds User field to Cloudfiles Updates [#117](https://github.com/fastly/cli/pull/117) +- Adds Region field to Scalyr [#116](https://github.com/fastly/cli/pull/116) +- Adds PublicKey field to S3 [#114](https://github.com/fastly/cli/pull/114) +- Adds MessageType field to GCS Updates [#113](https://github.com/fastly/cli/pull/113) +- Adds ResponseCondition and Placement fields to BigQuery Creates [#111](https://github.com/fastly/cli/pull/111) **Bug fixes:** -- Unable to login with API key [\#94](https://github.com/fastly/cli/issues/94) +- Unable to login with API key [#94](https://github.com/fastly/cli/issues/94) ## [v0.11.0](https://github.com/fastly/cli/releases/tag/v0.11.0) (2020-05-29) @@ -345,11 +2346,11 @@ **Enhancements:** -- Add ability to exclude files from build package [\#87](https://github.com/fastly/cli/pull/87) +- Add ability to exclude files from build package [#87](https://github.com/fastly/cli/pull/87) **Bug fixes:** -- unintended files included in upload package [\#24](https://github.com/fastly/cli/issues/24) +- unintended files included in upload package [#24](https://github.com/fastly/cli/issues/24) ## [v0.10.0](https://github.com/fastly/cli/releases/tag/v0.10.0) (2020-05-28) @@ -357,11 +2358,11 @@ **Enhancements:** -- Adds Google Cloud Pub/Sub logging endpoint support [\#96](https://github.com/fastly/cli/pull/96) -- Adds Datadog logging endpoint support [\#92](https://github.com/fastly/cli/pull/92) -- Adds HTTPS logging endpoint support [\#91](https://github.com/fastly/cli/pull/91) -- Adds Elasticsearch logging endpoint support [\#90](https://github.com/fastly/cli/pull/90) -- Adds Azure Blob Storage logging endpoint support [\#89](https://github.com/fastly/cli/pull/89) +- Adds Google Cloud Pub/Sub logging endpoint support [#96](https://github.com/fastly/cli/pull/96) +- Adds Datadog logging endpoint support [#92](https://github.com/fastly/cli/pull/92) +- Adds HTTPS logging endpoint support [#91](https://github.com/fastly/cli/pull/91) +- Adds Elasticsearch logging endpoint support [#90](https://github.com/fastly/cli/pull/90) +- Adds Azure Blob Storage logging endpoint support [#89](https://github.com/fastly/cli/pull/89) ## [v0.9.0](https://github.com/fastly/cli/releases/tag/v0.9.0) (2020-05-21) @@ -369,31 +2370,31 @@ **Breaking changes:** -- Describe subcommand consistent --name short flag -d -\> -n [\#85](https://github.com/fastly/cli/pull/85) +- Describe subcommand consistent --name short flag -d -> -n [#85](https://github.com/fastly/cli/pull/85) **Enhancements:** -- Adds Kafka logging endpoint support [\#95](https://github.com/fastly/cli/pull/95) -- Adds DigitalOcean Spaces logging endpoint support [\#80](https://github.com/fastly/cli/pull/80) -- Adds Rackspace Cloudfiles logging endpoint support [\#79](https://github.com/fastly/cli/pull/79) -- Adds Log Shuttle logging endpoint support [\#78](https://github.com/fastly/cli/pull/78) -- Adds SFTP logging endpoint support [\#77](https://github.com/fastly/cli/pull/77) -- Adds Heroku logging endpoint support [\#76](https://github.com/fastly/cli/pull/76) -- Adds Honeycomb logging endpoint support [\#75](https://github.com/fastly/cli/pull/75) -- Adds Loggly logging endpoint support [\#74](https://github.com/fastly/cli/pull/74) -- Adds Scalyr logging endpoint support [\#73](https://github.com/fastly/cli/pull/73) -- Verify fastly crate version during compute build. [\#67](https://github.com/fastly/cli/pull/67) -- Basic support for historical & realtime stats [\#66](https://github.com/fastly/cli/pull/66) -- Adds Splunk endpoint [\#64](https://github.com/fastly/cli/pull/64) -- Adds FTP logging endpoint support [\#63](https://github.com/fastly/cli/pull/63) -- Adds GCS logging endpoint support [\#62](https://github.com/fastly/cli/pull/62) -- Adds Sumo Logic logging endpoint support [\#59](https://github.com/fastly/cli/pull/59) -- Adds Papertrail logging endpoint support [\#57](https://github.com/fastly/cli/pull/57) -- Adds Logentries logging endpoint support [\#56](https://github.com/fastly/cli/pull/56) +- Adds Kafka logging endpoint support [#95](https://github.com/fastly/cli/pull/95) +- Adds DigitalOcean Spaces logging endpoint support [#80](https://github.com/fastly/cli/pull/80) +- Adds Rackspace Cloudfiles logging endpoint support [#79](https://github.com/fastly/cli/pull/79) +- Adds Log Shuttle logging endpoint support [#78](https://github.com/fastly/cli/pull/78) +- Adds SFTP logging endpoint support [#77](https://github.com/fastly/cli/pull/77) +- Adds Heroku logging endpoint support [#76](https://github.com/fastly/cli/pull/76) +- Adds Honeycomb logging endpoint support [#75](https://github.com/fastly/cli/pull/75) +- Adds Loggly logging endpoint support [#74](https://github.com/fastly/cli/pull/74) +- Adds Scalyr logging endpoint support [#73](https://github.com/fastly/cli/pull/73) +- Verify fastly crate version during compute build. [#67](https://github.com/fastly/cli/pull/67) +- Basic support for historical & realtime stats [#66](https://github.com/fastly/cli/pull/66) +- Adds Splunk endpoint [#64](https://github.com/fastly/cli/pull/64) +- Adds FTP logging endpoint support [#63](https://github.com/fastly/cli/pull/63) +- Adds GCS logging endpoint support [#62](https://github.com/fastly/cli/pull/62) +- Adds Sumo Logic logging endpoint support [#59](https://github.com/fastly/cli/pull/59) +- Adds Papertrail logging endpoint support [#57](https://github.com/fastly/cli/pull/57) +- Adds Logentries logging endpoint support [#56](https://github.com/fastly/cli/pull/56) **Bug fixes:** -- Fallback to a file copy during update if the file rename fails [\#72](https://github.com/fastly/cli/pull/72) +- Fallback to a file copy during update if the file rename fails [#72](https://github.com/fastly/cli/pull/72) ## [v0.8.0](https://github.com/fastly/cli/releases/tag/v0.8.0) (2020-05-13) @@ -401,18 +2402,18 @@ **Enhancements:** -- Add a --force flag to compute build to skip verification steps. [\#68](https://github.com/fastly/cli/pull/68) -- Improve `compute build` rust compilation error messaging [\#60](https://github.com/fastly/cli/pull/60) -- Adds Syslog logging endpoint support [\#55](https://github.com/fastly/cli/pull/55) +- Add a --force flag to compute build to skip verification steps. [#68](https://github.com/fastly/cli/pull/68) +- Improve `compute build` rust compilation error messaging [#60](https://github.com/fastly/cli/pull/60) +- Adds Syslog logging endpoint support [#55](https://github.com/fastly/cli/pull/55) **Bug fixes:** -- debian package doesn't install in default $PATH [\#58](https://github.com/fastly/cli/issues/58) -- deb and rpm packages install the binary in `/usr/local` instead of `/usr/local/bin` [\#53](https://github.com/fastly/cli/issues/53) +- debian package doesn't install in default $PATH [#58](https://github.com/fastly/cli/issues/58) +- deb and rpm packages install the binary in `/usr/local` instead of `/usr/local/bin` [#53](https://github.com/fastly/cli/issues/53) **Closed issues:** -- ERROR: error during compilation process: exit status 101. [\#52](https://github.com/fastly/cli/issues/52) +- ERROR: error during compilation process: exit status 101. [#52](https://github.com/fastly/cli/issues/52) ## [v0.7.1](https://github.com/fastly/cli/releases/tag/v0.7.1) (2020-05-04) @@ -420,7 +2421,7 @@ **Bug fixes:** -- Ensure compute deploy selects the most ideal version to clone/activate [\#50](https://github.com/fastly/cli/pull/50) +- Ensure compute deploy selects the most ideal version to clone/activate [#50](https://github.com/fastly/cli/pull/50) ## [v0.7.0](https://github.com/fastly/cli/releases/tag/v0.7.0) (2020-04-28) @@ -428,13 +2429,13 @@ **Enhancements:** -- Publish scoop package manifest during release process [\#45](https://github.com/fastly/cli/pull/45) -- Generate dep and rpm packages during release process [\#44](https://github.com/fastly/cli/pull/44) -- 🦀 🆙date to Rust 1.43.0 [\#40](https://github.com/fastly/cli/pull/40) +- Publish scoop package manifest during release process [#45](https://github.com/fastly/cli/pull/45) +- Generate dep and rpm packages during release process [#44](https://github.com/fastly/cli/pull/44) +- 🦀 🆙date to Rust 1.43.0 [#40](https://github.com/fastly/cli/pull/40) **Closed issues:** -- README's build instructions do not work without additional dependencies met [\#35](https://github.com/fastly/cli/issues/35) +- README's build instructions do not work without additional dependencies met [#35](https://github.com/fastly/cli/issues/35) ## [v0.6.0](https://github.com/fastly/cli/releases/tag/v0.6.0) (2020-04-24) @@ -442,16 +2443,16 @@ **Enhancements:** -- Bump default Rust template to v0.3.0 [\#32](https://github.com/fastly/cli/pull/32) -- Publish to homebrew [\#26](https://github.com/fastly/cli/pull/26) +- Bump default Rust template to v0.3.0 [#32](https://github.com/fastly/cli/pull/32) +- Publish to homebrew [#26](https://github.com/fastly/cli/pull/26) **Bug fixes:** -- Don't display the fastly token in the terminal when doing `fastly configure` [\#27](https://github.com/fastly/cli/issues/27) -- Documentation typo in `fastly service-version update` [\#22](https://github.com/fastly/cli/issues/22) -- Fix typo in service-version update command [\#31](https://github.com/fastly/cli/pull/31) -- Tidy up `fastly configure` text output [\#30](https://github.com/fastly/cli/pull/30) -- compute/init: make space after Author prompt match other prompts [\#25](https://github.com/fastly/cli/pull/25) +- Don't display the fastly token in the terminal when doing `fastly configure` [#27](https://github.com/fastly/cli/issues/27) +- Documentation typo in `fastly service-version update` [#22](https://github.com/fastly/cli/issues/22) +- Fix typo in service-version update command [#31](https://github.com/fastly/cli/pull/31) +- Tidy up `fastly configure` text output [#30](https://github.com/fastly/cli/pull/30) +- compute/init: make space after Author prompt match other prompts [#25](https://github.com/fastly/cli/pull/25) ## [v0.5.0](https://github.com/fastly/cli/releases/tag/v0.5.0) (2020-04-08) @@ -459,7 +2460,7 @@ **Enhancements:** -- Add the ability to initialise a compute project from a specific branch [\#14](https://github.com/fastly/cli/pull/14) +- Add the ability to initialise a compute project from a specific branch [#14](https://github.com/fastly/cli/pull/14) ## [v0.4.1](https://github.com/fastly/cli/releases/tag/v0.4.1) (2020-03-27) @@ -467,8 +2468,8 @@ **Bug fixes:** -- Fix persistence of author string to fastly.toml [\#12](https://github.com/fastly/cli/pull/12) -- Fix up undoStack.RunIfError [\#11](https://github.com/fastly/cli/pull/11) +- Fix persistence of author string to fastly.toml [#12](https://github.com/fastly/cli/pull/12) +- Fix up undoStack.RunIfError [#11](https://github.com/fastly/cli/pull/11) ## [v0.4.0](https://github.com/fastly/cli/releases/tag/v0.4.0) (2020-03-20) @@ -476,13 +2477,13 @@ **Enhancements:** -- Add commands for S3 logging endpoints [\#9](https://github.com/fastly/cli/pull/9) -- Add useful next step links to compute deploy [\#8](https://github.com/fastly/cli/pull/8) -- Persist version to manifest file when deploying compute services [\#7](https://github.com/fastly/cli/pull/7) +- Add commands for S3 logging endpoints [#9](https://github.com/fastly/cli/pull/9) +- Add useful next step links to compute deploy [#8](https://github.com/fastly/cli/pull/8) +- Persist version to manifest file when deploying compute services [#7](https://github.com/fastly/cli/pull/7) **Bug fixes:** -- Fix comment for --use-ssl flag [\#6](https://github.com/fastly/cli/pull/6) +- Fix comment for --use-ssl flag [#6](https://github.com/fastly/cli/pull/6) ## [v0.3.0](https://github.com/fastly/cli/releases/tag/v0.3.0) (2020-03-11) @@ -490,7 +2491,7 @@ **Enhancements:** -- Interactive init [\#5](https://github.com/fastly/cli/pull/5) +- Interactive init [#5](https://github.com/fastly/cli/pull/5) ## [v0.2.0](https://github.com/fastly/cli/releases/tag/v0.2.0) (2020-02-24) @@ -498,11 +2499,11 @@ **Enhancements:** -- Improve toolchain installation help messaging [\#3](https://github.com/fastly/cli/pull/3) +- Improve toolchain installation help messaging [#3](https://github.com/fastly/cli/pull/3) **Bug fixes:** -- Filter unwanted files from template repository whilst initialising [\#1](https://github.com/fastly/cli/pull/1) +- Filter unwanted files from template repository whilst initialising [#1](https://github.com/fastly/cli/pull/1) ## [v0.1.0](https://github.com/fastly/cli/releases/tag/v0.1.0) (2020-02-05) @@ -510,6 +2511,4 @@ Initial release :tada: - - \* *This Changelog was automatically generated by [github_changelog_generator](https://github.com/github-changelog-generator/github-changelog-generator)* diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9f080c71d..022370189 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,15 +5,15 @@ please open an [issue](https://github.com/fastly/cli/issues/new) to discuss the idea and implementation strategy before submitting a PR. 1. Fork the repository. -1. Create an `upstream` remote. +2. Create an `upstream` remote. ```bash $ git remote add upstream git@github.com:fastly/cli.git ``` -1. Create a feature branch. -1. Write tests. -1. Validate and prepare your change. -```bash -$ make all -``` -1. Open a pull request against `upstream main`. -1. Celebrate :tada:! +3. Create a feature branch. +4. Write tests. +5. Run the linter and formatter `make all`. + 1. You may need to install [golangci-lint](https://golangci-lint.run/welcome/install/) if you don't have it installed +6. Add your changes to `CHANGELOG.md` in [Commitizen](https://commitizen-tools.github.io/commitizen/) style message +7. Open a pull request against `upstream main`. + 1. Once you have marked your PR as `Ready for Review` please do not force push to the branch +8. Celebrate :tada:! diff --git a/DEVELOP.md b/DEVELOP.md deleted file mode 100644 index 600527183..000000000 --- a/DEVELOP.md +++ /dev/null @@ -1,20 +0,0 @@ -## Development - -The Fastly CLI requires [Go 1.16 or above](https://golang.org). Clone this repo -to any path and type `make` to run all of the tests and generate a development -build locally. - -```sh -git clone git@github.com:fastly/cli -cd cli -make -./fastly version -``` - -The `make` task requires the following executables to exist in your `$PATH`: - -- [golint](https://github.com/golang/lint) -- [gosec](https://github.com/securego/gosec) -- [staticcheck](https://staticcheck.io/) - -If you have none of them installed, or don't mind them being upgraded automatically, you can run `make dependencies` to install them. diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md new file mode 100644 index 000000000..5d446ecdf --- /dev/null +++ b/DEVELOPMENT.md @@ -0,0 +1,81 @@ +## Development + +Building the Fastly CLI requires [Go](https://golang.org) (version +1.18 or later), and [Rust](https://www.rust-lang.org/). Clone this +repo to any path and type `make` to run all of the tests and generate +a development build locally. + +```sh +git clone git@github.com:fastly/cli +cd cli +make +./fastly version +``` + +The `make` task requires the following executables to exist in your `$PATH`: + +- [golint](https://github.com/golang/lint) +- [gosec](https://github.com/securego/gosec) +- [staticcheck](https://staticcheck.io/) + +If you have none of them installed, or don't mind them being upgraded automatically, you can run `make mod-download` to install them. + +### New API Command Scaffolding + +There are two ways to scaffold a new command, that is intended to be a non-composite command (i.e. a straight 1-1 mapping to an underlying Fastly API endpoint): + +1. `make scaffold`: e.g. `fastly foo ` +2. `make scaffold-category`: e.g. `fastly foo bar ` + +The latter `Makefile` target is for commands we want to group under a common category (in the above example `foo` is the category and `bar` is the command). A real example of a category command would be `fastly logging`, where `logging` is the category and within that category are multiple logging provider commands (e.g. `fastly logging splunk`, where `splunk` is the command). + +The `logging` category is an otherwise non-functional command (i.e. if you execute `fastly logging`, then all you see is help output describing the available commands under the logging category). + +**Makefile target structure:** + +```bash +CLI_PACKAGE=... CLI_COMMAND=... CLI_API=... make scaffold +CLI_CATEGORY=... CLI_CATEGORY_COMMAND=... CLI_PACKAGE=... CLI_COMMAND=... CLI_API=... make scaffold-category +``` + +**Example usage:** + +Imagine you want to add the following top-level command `fastly foo-bar` with CRUD commands beneath it (e.g. `fastly foo `), then you would execute: + +```bash +CLI_PACKAGE=foobar CLI_COMMAND=foo-bar CLI_API=Bar make scaffold +``` + +> **NOTE**: Go package names shouldn't have special characters, hence the difference between `foobar` and the command `foo-bar`. Also, the `CLI_API` value will be interpolated into CRUD verbs (`CreateBar`, `DeleteBar` etc), along with their inputs (`fastly.CreateBarInput`, `fastly.DeleteBarInput` etc). + +Now imagine you want to add a new subcommand to an existing category such as 'logging' `fastly logging foo-bar` with CRUD commands beneath it (e.g. `fastly logging foo-bar `), then you would execute a similar command but you would change to the `scaffold-category` target and also prefix two additional inputs: + +```bash +CLI_CATEGORY=logging CLI_CATEGORY_COMMAND=logging CLI_PACKAGE=foobar CLI_COMMAND=foo-bar CLI_API=Bar make scaffold-category +``` + +> **NOTE**: Within the generated files, keep an eye out for any `<...>` references that need to be manually updated. + +### `.fastly/config.toml` + +The CLI dynamically generates the `./pkg/config/config.toml` within the CI release process so it can be embedded into the CLI binary. + +The file is added to `.gitignore` to avoid it being added to the git repository. + +When compiling the CLI for a new release, it will execute [`./scripts/config.sh`](./scripts/config.sh). The script uses [`./.fastly/config.toml`](./.fastly/config.toml) as a template file to then dynamically inject a list of starter kits (pulling their data from their public repositories). + +The resulting configuration is then saved to disk at `./pkg/config/config.toml` and embedded into the CLI when compiled. + +When a user installs the CLI for the first time, they'll have no existing config and so the embedded config will be used. In the future, when the user updates their CLI, the existing config they have will be used. + +If the config has changed in any way, then you (the CLI developer) should ensure the `config_version` number is bumped before publishing a new CLI release. This is because when the user updates to that new CLI version and the invoke the CLI, the CLI will identify a mismatch between the user's local config version and the embedded config version. This will cause the embedded config to be merged with the local config and consequently the user's config will be updated to include the new fields. + +> **NOTE:** The CLI does provide a `fastly config --reset` option that resets the config to a version compatible with the user's current CLI version. This is fallback for users who run into issues for whatever reason. + +### Running Compute commands locally + +If you need to test the Fastly CLI locally while developing a Compute feature, then use the `--dir` flag (exposed on `compute build`, `compute deploy`, `compute serve` and `compute publish`) to ensure the CLI doesn't attempt to treat the repository directory as your project directory. + +```shell +go run cmd/fastly/main.go compute deploy --verbose --dir ../../test-projects/testing-fastly-cli +``` diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md new file mode 100644 index 000000000..5fad20d66 --- /dev/null +++ b/DOCUMENTATION.md @@ -0,0 +1,7 @@ +## Documentation + +The help output from the Fastly CLI is consumed by the Fastly Developer Hub to produce online documentation: https://www.fastly.com/documentation/reference/cli + +Part of the documentation is to provide additional usage examples and links to APIs used by the CLI commands (example: https://www.fastly.com/documentation/reference/clibackend/create/#examples). + +These examples and API references are defined in [`pkg/app/metadata.json`](./pkg/app/metadata.json). diff --git a/Dockerfile-node b/Dockerfile-node new file mode 100644 index 000000000..80d682bb5 --- /dev/null +++ b/Dockerfile-node @@ -0,0 +1,21 @@ +FROM node:latest +LABEL maintainer="Fastly OSS " + +RUN apt-get update && apt-get install -y curl jq && apt-get -y clean && rm -rf /var/lib/apt/lists/* \ + && export FASTLY_CLI_VERSION=$(curl -s https://api.github.com/repos/fastly/cli/releases/latest | jq -r .tag_name | cut -d 'v' -f 2) \ + GOARCH=$(dpkg --print-architecture) \ + && curl -sL "https://github.com/fastly/cli/releases/download/v${FASTLY_CLI_VERSION}/fastly_v${FASTLY_CLI_VERSION}_linux-$GOARCH.tar.gz" -o fastly.tar.gz \ + && curl -sL "https://github.com/fastly/cli/releases/download/v${FASTLY_CLI_VERSION}/fastly_v${FASTLY_CLI_VERSION}_SHA256SUMS" -o sha256sums \ + && dlsha=$(shasum -a 256 fastly.tar.gz | cut -d " " -f 1) && expected=$(cat sha256sums | awk -v pat="$dlsha" '$0~pat' | cut -d " " -f 1) \ + && if [ "$dlsha" != "$expected" ]; then echo "shasums don't match" && exit 1; fi \ + && tar -xzf fastly.tar.gz --directory /usr/bin && rm -f sha256sums fastly.tar.gz \ + && useradd -ms /bin/bash fastly + +USER fastly + +WORKDIR /app +ENTRYPOINT ["/usr/bin/fastly"] +CMD ["--help"] + +# docker build -t fastly/cli/node . -f ./Dockerfile-node +# docker run -v $PWD:/app -it -p 7676:7676 fastly/cli/node compute serve --addr="0.0.0.0:7676" diff --git a/Dockerfile-rust b/Dockerfile-rust new file mode 100644 index 000000000..1c051b684 --- /dev/null +++ b/Dockerfile-rust @@ -0,0 +1,24 @@ +FROM rust:latest +LABEL maintainer="Fastly OSS " + +ENV RUST_TOOLCHAIN=stable +RUN rustup toolchain install ${RUST_TOOLCHAIN} \ + && rustup target add wasm32-wasip1 --toolchain ${RUST_TOOLCHAIN} \ + && apt-get update && apt-get install -y curl jq && apt-get -y clean && rm -rf /var/lib/apt/lists/* \ + && export FASTLY_CLI_VERSION=$(curl -s https://api.github.com/repos/fastly/cli/releases/latest | jq -r .tag_name | cut -d 'v' -f 2) \ + GOARCH=$(dpkg --print-architecture) \ + && curl -sL "https://github.com/fastly/cli/releases/download/v${FASTLY_CLI_VERSION}/fastly_v${FASTLY_CLI_VERSION}_linux-$GOARCH.tar.gz" -o fastly.tar.gz \ + && curl -sL "https://github.com/fastly/cli/releases/download/v${FASTLY_CLI_VERSION}/fastly_v${FASTLY_CLI_VERSION}_SHA256SUMS" -o sha256sums \ + && dlsha=$(shasum -a 256 fastly.tar.gz | cut -d " " -f 1) && expected=$(cat sha256sums | awk -v pat="$dlsha" '$0~pat' | cut -d " " -f 1) \ + && if [ "$dlsha" != "$expected" ]; then echo "shasums don't match" && exit 1; fi \ + && tar -xzf fastly.tar.gz --directory /usr/bin && rm -f sha256sums fastly.tar.gz \ + && useradd -ms /bin/bash fastly + +USER fastly + +WORKDIR /app +ENTRYPOINT ["/usr/bin/fastly"] +CMD ["--help"] + +# docker build -t fastly/cli/rust . -f ./Dockerfile-rust +# docker run -v $PWD:/app -it -p 7676:7676 fastly/cli/rust compute serve --addr="0.0.0.0:7676" diff --git a/Makefile b/Makefile index 15e69a5e2..676e6b103 100644 --- a/Makefile +++ b/Makefile @@ -1,75 +1,138 @@ -SHELL := /bin/bash -o pipefail +.PHONY: clean -# the rationale for using both `git describe` and `git rev-parse` is because -# when CI builds the application it can be based on a git tag, so this ensures -# the output is consistent across environments. -VERSION ?= $(shell git describe --tags 2>/dev/null || git rev-parse --short HEAD) +SHELL := /usr/bin/env bash -o pipefail ## Set the shell to use for finding Go files (default: /bin/bash) -TESTARGS ?= ./{cmd,pkg}/... -LDFLAGS = -ldflags "\ - -X 'github.com/fastly/cli/pkg/revision.AppVersion=${VERSION}' \ - -X 'github.com/fastly/cli/pkg/revision.GitCommit=$(shell git rev-parse --short HEAD || echo unknown)' \ - -X 'github.com/fastly/cli/pkg/revision.GoVersion=$(shell go version)' \ - " - -fastly: - @go build -trimpath $(LDFLAGS) -o "$@" ./cmd/fastly - -# useful for attaching a debugger such as https://github.com/go-delve/delve +# Compile program (implicit default target). +# +# GO_ARGS allows for passing additional arguments. +# e.g. make build GO_ARGS='--ldflags "-s -w"' +.PHONY: build +build: config ## Compile program (CGO disabled) + CGO_ENABLED=0 $(GO_BIN) build $(GO_ARGS) ./cmd/fastly + +## Allows overriding go executable. +GO_BIN ?= go +## Enables support for tools such as https://github.com/rakyll/gotest +TEST_COMMAND ?= $(GO_BIN) test +## The compute tests can sometimes exceed the default 10m limit +TEST_ARGS ?= -v -timeout 15m ./... + +ifeq ($(OS), Windows_NT) + SHELL = cmd.exe + .SHELLFLAGS = /c + GO_FILES = $(shell where /r pkg *.go) + GO_FILES += $(shell where /r cmd *.go) + CONFIG_SCRIPT = scripts\config.sh + CONFIG_FILE = pkg\config\config.toml +else + GO_FILES = $(shell find cmd pkg -type f -name '*.go') + CONFIG_SCRIPT = ./scripts/config.sh + CONFIG_FILE = pkg/config/config.toml +endif + +# Build executables using goreleaser (useful for local testing purposes). +# +# You can pass flags to goreleaser via GORELEASER_ARGS +# --clean will save you deleting the dist dir +# --single-target will be quicker and only build for your os & architecture +# --skip=post-hooks which prevents errors such as trying to execute the binary for each OS (e.g. we call scripts/documentation.sh and we can't run Windows exe on a Mac). +# --skip=validate will skip the checks (e.g. git tag checks which result in a 'dirty git state' error) +# +# EXAMPLE: +# make release GORELEASER_ARGS="--clean --skip=post-hooks --skip=validate" +release: dependencies $(GO_FILES) ## Build executables using goreleaser + $(GO_BIN) tool -modfile=tools.mod goreleaser build ${GORELEASER_ARGS} + +# Useful for attaching a debugger such as https://github.com/go-delve/delve debug: - @go build -gcflags="all=-N -l" -o "fastly" ./cmd/fastly + @$(GO_BIN) build -gcflags="all=-N -l" $(GO_ARGS) -o "fastly" ./cmd/fastly + +.PHONY: config +config: + @$(CONFIG_SCRIPT) .PHONY: all -all: tidy fmt vet staticcheck lint gosec test build install +all: config mod-download tidy fmt lint semgrep test build install ## Run EVERYTHING! -.PHONY: dependencies -dependencies: - go get -v -u github.com/securego/gosec/cmd/gosec - go get -v -u honnef.co/go/tools/cmd/staticcheck - go get -v -u golang.org/x/lint/golint +## Downloads the Go modules +mod-download: + @echo "==> Downloading Go module" + @$(GO_BIN) mod download +.PHONY: mod-download +# Clean up Go modules file. .PHONY: tidy tidy: - go mod tidy + $(GO_BIN) mod tidy +# Run formatter. .PHONY: fmt fmt: - @echo gofmt -l ./{cmd,pkg} - @eval "bash -c 'F=\$$(gofmt -l ./{cmd,pkg}) ; if [[ \$$F ]] ; then echo \$$F ; exit 1 ; fi'" - -.PHONY: vet -vet: - go vet ./{cmd,pkg}/... - -.PHONY: gosec -gosec: - gosec -quiet -exclude=G104 ./{cmd,pkg}/... - -.PHONY: staticcheck -staticcheck: - staticcheck ./{cmd,pkg}/... + golangci-lint fmt + +# Run semgrep checker. +# NOTE: We can only exclude the import-text-template rule via a semgrep CLI flag +.PHONY: semgrep +semgrep: ## Run semgrep + @if [ "$$(uname)" = 'Darwin' ]; then \ + if ! command -v semgrep &> /dev/null; then \ + brew install semgrep; \ + fi \ + fi + @if [ '$(SEMGREP_SKIP)' != 'true' ]; then \ + if command -v semgrep &> /dev/null; then semgrep ci --config auto --exclude-rule go.lang.security.audit.xss.import-text-template.import-text-template $(SEMGREP_ARGS); fi \ + fi .PHONY: lint -lint: - golint ./{cmd,pkg}/... +lint: ## Run golangci-lint + golangci-lint run --verbose +# Run tests .PHONY: test -test: - go test -race $(TESTARGS) - -.PHONY: build -build: - go build ./cmd/fastly +test: config ## Run tests (with race detection) + @$(TEST_COMMAND) -race $(TEST_ARGS) +# Compile and install program. +# +# GO_ARGS allows for passing additional arguments. .PHONY: install -install: - go install ./cmd/fastly - -.PHONY: changelog -changelog: - @$(shell pwd)/scripts/changelog.sh - -.PHONY: release-changelog -release-changelog: - @$(shell pwd)/scripts/release-changelog.sh +install: config ## Compile and install program + CGO_ENABLED=0 $(GO_BIN) install $(GO_ARGS) ./cmd/fastly + +# Scaffold a new CLI command from template files. +.PHONY: scaffold +scaffold: + @$(shell pwd)/scripts/scaffold.sh $(CLI_PACKAGE) $(CLI_COMMAND) $(CLI_API) + +# Scaffold a new CLI 'category' command from template files. +.PHONY: scaffold-category +scaffold-category: + @$(shell pwd)/scripts/scaffold-category.sh $(CLI_CATEGORY) $(CLI_CATEGORY_COMMAND) $(CLI_PACKAGE) $(CLI_COMMAND) $(CLI_API) + +# Graph generates a call graph that focuses on the specified package. +# Output is callvis.svg +# e.g. make graph PKG_IMPORT_PATH=github.com/fastly/cli/pkg/commands/kvstoreentry +.PHONY: graph +graph: ## Graph generates a call graph that focuses on the specified package + $(GO_BIN) tool -modfile=tools.mod go-callvis -file "callvis" -focus "$(PKG_IMPORT_PATH)" ./cmd/fastly/ + @rm callvis.gv + +.PHONY: deps-app-update +deps-app-update: ## Update all application dependencies + $(GO_BIN) get -u -d -t ./... + $(GO_BIN) mod tidy + if [ -d "vendor" ]; then $(GO_BIN) mod vendor; fi + +.PHONY: help +help: + @printf "Targets\n" + @(grep -h -E '^[0-9a-zA-Z_.-]+:.*?## .*$$' $(MAKEFILE_LIST) || true) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-22s\033[0m %s\n", $$1, $$2}' + @printf "\nDefault target\n" + @printf "\033[36m%s\033[0m" $(.DEFAULT_GOAL) + @printf "\n\nMake Variables\n" + @(grep -h -E '^[0-9a-zA-Z_.-]+\s[:?]?=.*? ## .*$$' $(MAKEFILE_LIST) || true) | sort | awk 'BEGIN {FS = "[:?]?=.*?## "}; {printf "\033[36m%-25s\033[0m %s\n", $$1, $$2}' + +.PHONY: run +run: config + $(GO_BIN) run cmd/fastly/main.go $(GO_ARGS) diff --git a/README.md b/README.md index 9245f2582..f0f748493 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@

Fastly CLI

A CLI for interacting with the Fastly platform.

- Documentation + Documentation Latest release Apache 2.0 License Go Report Card @@ -10,28 +10,57 @@ ## Quick links -- [Installation](https://developer.fastly.com/reference/cli/#installing) -- [Shell auto-completion](https://developer.fastly.com/reference/cli/#shell-auto-completion) -- [Configuring](https://developer.fastly.com/reference/cli/#configuring) -- [Commands](https://developer.fastly.com/reference/cli/#command-groups) -- [Development](DEVELOP.md) -- [Testing](TESTING.md) + +- [Installation](https://www.fastly.com/documentation/reference/tools/cli#installing) +- [Shell auto-completion](https://www.fastly.com/documentation/reference/tools/cli#shell-auto-completion) +- [Configuring](https://www.fastly.com/documentation/reference/tools/cli#configuring) +- [Commands](https://www.fastly.com/documentation/reference/cli#command-groups) +- [Development](https://github.com/fastly/cli/blob/main/DEVELOPMENT.md) +- [Testing](https://github.com/fastly/cli/blob/main/TESTING.md) +- [Documentation](https://github.com/fastly/cli/blob/main/DOCUMENTATION.md) + +## Versioning and Release Schedules + +The maintainers of this module strive to maintain [semantic versioning +(SemVer)](https://semver.org/). This means that breaking changes +(removal of functionality, or incompatible changes to existing +functionality) will be released in a version with the first version +component (`major`) incremented. Feature additions will increment the +second version component (`minor`), and bug fixes which do not affect +compatibility will increment the third version component (`patch`). + +On the second Wednesday of each month, a release will be published +including all breaking, feature, and bug-fix changes that are ready +for release. If that Wednesday should happen to be a US holiday, the +release will be delayed until the next available working day. + +If critical or urgent bug fixes are ready for release in between those +primary releases, patch releases will be made as needed to make those +fixes available. ## Contributing -Refer to [CONTRIBUTING.md](./CONTRIBUTING.md) +Refer to [CONTRIBUTING.md](https://github.com/fastly/cli/blob/main/CONTRIBUTING.md) ## Issues If you encounter any non-security-related bug or unexpected behavior, please [file an issue][bug] using the bug report template. -[bug]: https://github.com/fastly/cli/issues/new?labels=bug&template=bug_report.md +Please also check the [CHANGELOG](https://github.com/fastly/cli/blob/main/CHANGELOG.md) for any breaking-changes or migration guidance. ### Security issues Please see our [SECURITY.md](SECURITY.md) for guidance on reporting security-related issues. +## Binaries with unreleased changes + +Binaries containing merged changes that are planned for the next release are available [here](https://github.com/fastly/cli/actions/workflows/merge_to_main.yml). +Use at your own risk. +Updating will revert the binary to the latest released version. + ## License [Apache 2.0](LICENSE). + +[bug]: https://github.com/fastly/cli/issues/new?labels=bug&template=bug_report.md diff --git a/RELEASE.md b/RELEASE.md index a19a074a8..276c9b603 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,31 +1,36 @@ -# Releasing - -### How to cut a new release of the CLI - -This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html); therefore first determine the appropriate version tag based on the change set. If in doubt discuss with the team via Slack before releasing. - -1. Merge all PRs intended for the release into the `main` branch -1. Checkout and update the main branch and ensure all tests are passing: - * `git checkout main` - * `git pull` - * `make all` -1. Update the [`CHANGELOG.md`](https://github.com/fastly/cli/blob/main/CHANGELOG.md): - * Apply necessary labels (`enchancement`, `bug`, `documentation` etc) to all PRs intended for the release that you wish to appear in the `CHANGELOG.md` - * **Only add labels for relevant changes** - * `git checkout -b vx.x.x` where `vx.x.x` is your target version tag - * `CHANGELOG_GITHUB_TOKEN=xxxx SEMVER_TAG=vx.x.x make changelog` - * **Known Issue**: We've found that the diffs generated are non-deterministic. Just re-run `make changelog` until you get a diff with just the newest additions. For more details, visit [this link](https://github.com/github-changelog-generator/github-changelog-generator/issues/580#issuecomment-380952266). - * `git add CHANGELOG.md && git commit -m "vx.x.x"` -1. Send PR for the `CHANGELOG.md` -1. Once approved and merged, checkout and update the `main` branch: - * `git checkout main` - * `git pull` -1. Create a new tag for `main`: - * `git tag -s vx.x.x -m "vx.x.x"` -1. Push the new tag: - * `git push origin vx.x.x` -1. Go to GitHub and check that the release was successful: - * Check the release CI job status via the [Actions](https://github.com/fastly/cli/actions?query=workflow%3ARelease) tab - * Check the release exists with valid assets and changelog: https://github.com/fastly/cli/releases -1. Announce release internally via Slack -1. Celebrate :tada: +# Release Process + +1. Merge all PRs intended for the release. +1. Ensure any relevant `FIXME` notes in the code are addressed (e.g. `FIXME: remove this feature before next major release`). +1. Rebase latest remote main branch locally (`git pull --rebase origin main`). +1. Ensure all analysis checks and tests are passing (`time TEST_COMPUTE_INIT=1 TEST_COMPUTE_BUILD=1 TEST_COMPUTE_DEPLOY=1 make all`). +1. Ensure goreleaser builds locally (`make release GORELEASER_ARGS="--snapshot --skip=validate --skip=post-hooks --clean"`). +1. Open a new PR to update CHANGELOG ([example](https://github.com/fastly/cli/pull/273)). + - We utilize [semantic versioning](https://semver.org/) and only include relevant/significant changes within the CHANGELOG (be sure to document changes to the app config if `config_version` has changed, and if any breaking interface changes are made to the fastly.toml manifest those should be documented on https://fastly.com/documentation/developers). +1. Merge CHANGELOG. +1. Rebase latest remote main branch locally (`git pull --rebase origin main`). +1. Create a new signed tag (replace `{{remote}}` with the remote pointing to the official repository i.e. `origin` or `upstream` depending on your Git workflow): `tag=vX.Y.Z && git tag -s $tag -m $tag && git push {{remote}} $tag` + - Triggers a [github action](https://github.com/fastly/cli/blob/main/.github/workflows/tag_release.yml) that produces a 'draft' release. +1. Copy/paste CHANGELOG into the [draft release](https://github.com/fastly/cli/releases). +1. Publish draft release. + +## Creation of npm packages + +Each release of the Fastly CLI triggers a workflow in `.github/workflows/publish_release.yml` that results in the creation of a new version of the `@fastly/cli` npm package, as well as multiple packages each representing a supported platform/arch combo (e.g. `@fastly/cli-darwin-arm64`). These packages are given the same version number as the Fastly CLI release. The workflow then publishes the `@fastly/cli` package and the per-platform/arch packages to npmjs.com using the `NPM_TOKEN` secret in this repository. The per-platform/arch packages are generated on each release and not committed to source control. + +> [!NOTE] +> The workflow step that performs `npm version` in the directory of the `@fastly/cli` package triggers the execution of the `version` script listed in its `package.json`. In turn, this script creates the per-platform/arch packages. + +The `@fastly/cli` package is set up to declare the platform/arch-specific packages as `optionalDependencies`. When a package installs `@fastly/cli` as one of its `dependencies`, npm will additionally install just the platform/arch-specific package compatible with the environment. + +> [!NOTE] +> The `optionalDependencies` list only restricts the packages that are actually installed into the `node_modules` directory in an environment, and does not affect what is saved to the lockfile (`package-lock.json`). All the platform/arch-specific packages will be listed in the lockfile, so a single lockfile is safe to use in environments that may represent a different platform/arch combo. + +To see an example of the module layout, run: + +```sh +npm install @fastly/cli-darwin-arm64 --verbose +ls node_modules/@fastly/cli-darwin-arm64 +``` + +You should see a `fastly` executable binary as well as an `index.js` shim which allows the package to be imported as a module by other JavaScript projects. diff --git a/TESTING.md b/TESTING.md index 092f49bd8..afa89e34c 100644 --- a/TESTING.md +++ b/TESTING.md @@ -15,13 +15,13 @@ Note that by default the tests are run using `go test` with the following config To run a specific test use the `-run` flag (exposed by `go test`) and also provide the path to the directory where the test files reside (replace `...` and `` with appropriate values): ```sh -make test TESTARGS="-run <...> " +make test TEST_ARGS="-run <...> " ``` **Example**: ```sh -make test TESTARGS="-run TestBackendCreate ./pkg/backend/..." +make test TEST_ARGS="-run TestBackendCreate ./pkg/commands/backend" ``` Some integration tests aren't run outside of the CI environment, to enable these tests locally you'll need to set a specific environment variable relevant to the test. @@ -29,16 +29,43 @@ Some integration tests aren't run outside of the CI environment, to enable these The available environment variables are: - `TEST_COMPUTE_INIT`: runs `TestInit`. -- `TEST_COMPUTE_BUILD`: runs `TestBuildRust` and `TestBuildAssemblyScript`. +- `TEST_COMPUTE_BUILD`: runs `TestBuildRust`, `TestBuildJavaScript`, `TestBuildGo`. - `TEST_COMPUTE_BUILD_RUST`: runs `TestBuildRust`. -- `TEST_COMPUTE_BUILD_ASSEMBLYSCRIPT`: runs `TestBuildAssemblyScript`. +- `TEST_COMPUTE_BUILD_JAVASCRIPT`: runs `TestBuildJavaScript`. +- `TEST_COMPUTE_DEPLOY`: runs `TestDeploy`. **Example**: ```sh -TEST_COMPUTE_BUILD_RUST=1 make test TESTARGS="-run TestBuildRust/fastly_crate_prerelease ./pkg/compute/..." +TEST_COMPUTE_BUILD_RUST=1 make test TEST_ARGS="-run TestBuildRust/fastly_crate_prerelease ./pkg/compute/..." ``` When running the tests locally, if you don't have the relevant language ecosystems set-up properly then the tests will fail to run and you'll need to review the code to see what the remediation steps are, as that output doesn't get shown when running the test suite. -> **NOTE**: you might notice a discrepancy between CI and your local environment which is caused by the difference in Rust toolchain versions as defined in .github/workflows/pr_test.yml which specifies the version required to be tested for in CI. Running `rustup toolchain install ` and `rustup target add wasm32-wasi --toolchain ` will resolve any failing integration tests you may be running locally. +> **NOTE**: you might notice a discrepancy between CI and your local environment which is caused by the difference in Rust toolchain versions as defined in .github/workflows/pr_test.yml which specifies the version required to be tested for in CI. Running `rustup toolchain install ` and `rustup target add wasm32-wasip1 --toolchain ` will resolve any failing integration tests you may be running locally. + +To the run the full test suite: + +```sh +TEST_COMPUTE_INIT=1 TEST_COMPUTE_BUILD=1 TEST_COMPUTE_DEPLOY=1 TEST_COMMAND=gotest make all +``` + +> **NOTE**: `TEST_COMMAND` is optional and allows the use of https://github.com/rakyll/gotest to improve test output. + +### Debugging + +To debug failing tests you can use [Delve](<>). + +Essentially, `cd` into a package directory (where the `_test.go` file is you want to run) and then execute... + +``` +TEST_COMPUTE_BUILD=1 dlv test -- -test.v -test.run TestNameGoesHere +``` + +Once that is done, you can set breakpoints. For example: + +``` +break ../../app/run.go:152 +``` + +> **NOTE:** The path is relative to the package directory you're running the test file. diff --git a/cmd/fastly/main.go b/cmd/fastly/main.go index c22f33f78..31f6c9a02 100644 --- a/cmd/fastly/main.go +++ b/cmd/fastly/main.go @@ -1,224 +1,18 @@ +// Package main is the entry point for the Fastly CLI. package main import ( - "context" - "fmt" - "io" - "net/http" "os" - "strings" "github.com/fastly/cli/pkg/app" - "github.com/fastly/cli/pkg/check" - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/revision" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/cli/pkg/update" + fsterr "github.com/fastly/cli/pkg/errors" ) func main() { - // Some configuration options can come from env vars. - var env config.Environment - env.Read(parseEnv(os.Environ())) - - // All of the work of building the set of commands and subcommands, wiring - // them together, picking which one to call, and executing it, occurs in a - // helper function, Run. We parameterize all of the dependencies so we can - // test it more easily. Here, we declare all of the dependencies, using - // the "real" versions that pull e.g. actual commandline arguments, the - // user's real environment, etc. - var ( - args = os.Args[1:] - configFilePath = config.FilePath // write-only for `fastly configure` - clientFactory = app.FastlyAPIClient - httpClient = http.DefaultClient - cliVersioner = update.NewGitHub(context.Background(), "fastly", "cli", "fastly") - in io.Reader = os.Stdin - out io.Writer = common.NewSyncWriter(os.Stdout) - ) - - // We have to manually handle the inclusion of the verbose flag here because - // Kingpin doesn't evaluate the provided arguments until app.Run which - // happens later in the file and yet we need to know if we should be printing - // output related to the application configuration file in this file. - var verboseOutput bool - for _, seg := range args { - if seg == "-v" || seg == "--verbose" { - verboseOutput = true - } - } - - // Extract a subset of configuration options from the local application directory. - var file config.File - err := file.Read(configFilePath) - - if err != nil { - if verboseOutput { - if err == config.ErrLegacyConfig { - text.Output(out, ` - Found your local configuration file (required to use the CLI) was using a legacy format. - File is being upgraded now. - `) - } else { - text.Output(out, ` - Unable to locate a local configuration file (required to use the CLI). - File is being created now. - `) - } - text.Break(out) - } - } - - if err != nil || file.CLI.Version != revision.SemVer(revision.AppVersion) { - err := file.Load(config.RemoteEndpoint, httpClient) - if err != nil { - errors.RemediationError{ - Inner: err, - Remediation: errors.NetworkRemediation, - }.Print(os.Stderr) - os.Exit(1) - } - } - - // We have seen a situation where loading data from the remote - // config endpoint has caused a user to end up with a config in the - // non-legacy format but with empty values. - // - // It's unclear how this happens and so as a temporary measure we'll check if - // the in-memory data structure is missing a specific value that's set by the - // CLI, and if so we'll know something bad has happened because at this point - // we expect the data structure to have a non-empty string value. - // - // If we discover we're in that scenario we'll attempt to re-load the - // configuration from the remote endpoint. - if file.CLI.LastChecked == "" { - if verboseOutput { - text.Warning(out, ` - There was a problem loading the compatibility and versioning information for the Fastly CLI. - The operation will be retried as this configuration is required. - `) - text.Break(out) - } - - err := file.Load(config.RemoteEndpoint, httpClient) - if err != nil { - errors.RemediationError{ - Inner: err, - Remediation: errors.NetworkRemediation, - }.Print(os.Stderr) - os.Exit(1) - } - } - - // When the local configuration file is stale we'll need to acquire the - // latest version and write that back to disk. To ensure the CLI program - // doesn't complete before the write has finished, we block via a channel. - waitForWrite := make(chan bool) - wait := false - - var errLoadConfig error - - // Validate if configuration is older than its TTL - if check.Stale(file.CLI.LastChecked, file.CLI.TTL) { - if verboseOutput { - text.Info(out, ` -Compatibility and versioning information for the Fastly CLI is being updated in the background. The updated data will be used next time you execute a fastly command. - `) + if err := app.Run(os.Args, os.Stdin); err != nil { + if skipExit := fsterr.Process(err, os.Args, os.Stdout); skipExit { + return } - - wait = true - go func() { - // NOTE: we no longer use the hardcoded config.RemoteEndpoint constant. - // Instead we rely on the values inside of the application - // configuration file to determine where to load the config from. - err := file.Load(file.CLI.RemoteConfig, httpClient) - if err != nil { - errLoadConfig = errors.RemediationError{ - Inner: fmt.Errorf("there was a problem updating the versioning information for the Fastly CLI:\n\n%w", err), - Remediation: errors.BugRemediation, - } - } - - waitForWrite <- true - }() - } - - // Main is basically just a shim to call Run, so we do that here. - if err := app.Run(args, env, file, configFilePath, clientFactory, httpClient, cliVersioner, in, out); err != nil { - errors.Deduce(err).Print(os.Stderr) - - // NOTE: if we have an error processing the command, then we should be sure - // to wait for the async file write to complete (otherwise we'll end up in - // a situation where there is a local application configuration file but - // with incomplete contents). - // - // It would have been nice to just do something like... - // - // if wait { - // defer func(){ - // <-waitForWrite - // afterWrite(verboseOutput, errLoadConfig, out) - // }() - // } - // - // ...and to have this a bit further up the script, as it would have meant - // we could avoid duplicating the following if statement in two places. - // - // As it is, we have to wait for the async write operation here and also at - // the end of the main function. - // - // The problem with defer is that it doesn't work when os.Exit() is - // encountered, so you either use something like runtime.Goexit() which is - // pretty hairy and requires other changes like `defer os.Exit(0)` at the - // top of the main() function (it also has some funky side-effects related - // to how any other goroutines will persist and errors within those could - // cause other unexpected behaviour). The alternative is we re-architecture - // the entire call flow which isn't ideal either. - // - // So we've opted for duplication. - // - if wait { - <-waitForWrite - afterWrite(verboseOutput, errLoadConfig, out) - } - os.Exit(1) } - - // If the command being run finishes before the latest config is written back - // to disk, then wait for the write operation to complete. - // - // I use a variable instead of calling check.Stale() again, incase the file - // object has indeed been updated already and is no longer considered stale! - if wait { - <-waitForWrite - afterWrite(verboseOutput, errLoadConfig, out) - } -} - -// afterWrite determines what to do once our waitForWrite channel has received -// a message. The message indicates either the file was written successfully or -// that an error had occurred and so we should display an error message. -func afterWrite(verboseOutput bool, errLoadConfig error, out io.Writer) { - if verboseOutput && errLoadConfig == nil { - text.Info(out, config.UpdateSuccessful) - } - if errLoadConfig != nil { - errLoadConfig.(errors.RemediationError).Print(os.Stderr) - } -} - -func parseEnv(environ []string) map[string]string { - env := map[string]string{} - for _, kv := range environ { - toks := strings.SplitN(kv, "=", 2) - if len(toks) != 2 { - continue - } - k, v := toks[0], toks[1] - env[k] = v - } - return env } diff --git a/deb-copyright b/deb-copyright new file mode 100644 index 000000000..b22217fe8 --- /dev/null +++ b/deb-copyright @@ -0,0 +1,6 @@ +Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ +Source: https://github.com/fastly/cli +Upstream-Contact: https://github.com/fastly/cli/issues +License: Apache + On Debian systems, the complete text of the Apache version 2.0 license can be + found in `/usr/share/common-licenses/Apache-2.0'. diff --git a/go.mod b/go.mod index 36200b835..33a6a9058 100644 --- a/go.mod +++ b/go.mod @@ -1,34 +1,83 @@ module github.com/fastly/cli -go 1.16 +go 1.24.0 + +toolchain go1.24.3 require ( - github.com/Masterminds/semver/v3 v3.1.0 - github.com/ajg/form v1.5.1 // indirect - github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d // indirect + github.com/Masterminds/semver/v3 v3.3.1 + github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b // indirect + github.com/bep/debounce v1.2.1 github.com/blang/semver v3.5.1+incompatible - github.com/dustinkirkland/golang-petname v0.0.0-20191129215211-8e5a1ed0cff0 - github.com/fastly/go-fastly/v3 v3.5.0 + github.com/dustinkirkland/golang-petname v0.0.0-20240428194347-eebcea082ee0 github.com/fastly/kingpin v2.1.12-0.20191105091915-95d230a53780+incompatible - github.com/fatih/color v1.7.0 - github.com/frankban/quicktest v1.5.0 // indirect - github.com/google/go-cmp v0.3.1 - github.com/google/go-github/v28 v28.1.1 - github.com/hashicorp/go-cleanhttp v0.5.1 // indirect - github.com/kennygrant/sanitize v1.2.4 - github.com/mattn/go-colorable v0.1.7 // indirect - github.com/mholt/archiver v3.1.1+incompatible - github.com/mholt/archiver/v3 v3.3.0 - github.com/mitchellh/go-wordwrap v1.0.0 - github.com/mitchellh/mapstructure v1.3.2 - github.com/nicksnyder/go-i18n v1.10.1 // indirect - github.com/pelletier/go-toml v1.8.1 - github.com/pierrec/lz4 v2.3.0+incompatible // indirect + github.com/fatih/color v1.18.0 + github.com/fsnotify/fsnotify v1.9.0 + github.com/google/go-cmp v0.7.0 + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mholt/archiver/v3 v3.5.1 + github.com/mitchellh/go-wordwrap v1.0.1 + github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c + github.com/nicksnyder/go-i18n v1.10.3 // indirect + github.com/pelletier/go-toml v1.9.5 github.com/segmentio/textio v1.2.0 github.com/tomnomnom/linkheader v0.0.0-20180905144013-02ca5825eb80 - golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 - golang.org/x/net v0.0.0-20201021035429-f5854403a974 // indirect - gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect - gopkg.in/src-d/go-git.v4 v4.13.1 - gopkg.in/yaml.v2 v2.3.0 // indirect + golang.org/x/sys v0.33.0 // indirect + golang.org/x/term v0.32.0 ) + +require ( + github.com/fastly/go-fastly/v10 v10.2.0 + github.com/hashicorp/cap v0.9.0 + github.com/kennygrant/sanitize v1.2.4 + github.com/otiai10/copy v1.14.1 + github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 + github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 + github.com/theckman/yacspin v0.13.12 + golang.org/x/crypto v0.38.0 + golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 + golang.org/x/mod v0.24.0 +) + +require ( + github.com/dnaeon/go-vcr v1.2.0 // indirect + github.com/go-jose/go-jose/v4 v4.0.5 // indirect + github.com/kr/pretty v0.3.1 // indirect + github.com/otiai10/mint v1.6.3 // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect + github.com/stretchr/testify v1.10.0 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect +) + +require ( + github.com/andybalholm/brotli v1.1.1 // indirect + github.com/coreos/go-oidc/v3 v3.14.1 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5 // indirect + github.com/go-jose/go-jose/v3 v3.0.4 // indirect + github.com/golang/snappy v1.0.0 // indirect + github.com/google/go-querystring v1.1.0 // indirect + github.com/google/jsonapi v1.0.0 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-hclog v1.6.3 // indirect + github.com/hashicorp/go-uuid v1.0.3 // indirect + github.com/klauspost/compress v1.18.0 // indirect + github.com/klauspost/pgzip v1.2.6 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/nwaples/rardecode v1.1.3 // indirect + github.com/peterhellberg/link v1.2.0 // indirect + github.com/pierrec/lz4/v4 v4.1.22 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/ulikunitz/xz v0.5.12 // indirect + github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect + golang.org/x/net v0.40.0 // indirect + golang.org/x/oauth2 v0.30.0 // indirect + golang.org/x/sync v0.14.0 // indirect + golang.org/x/text v0.25.0 + gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) + +require 4d63.com/optional v0.2.0 diff --git a/go.sum b/go.sum index ecaf2aa1c..1ac565a85 100644 --- a/go.sum +++ b/go.sum @@ -1,191 +1,225 @@ -github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/Masterminds/semver/v3 v3.1.0 h1:Y2lUDsFKVRSYGojLJ1yLxSXdMmMYTYls0rCvoqmMUQk= -github.com/Masterminds/semver/v3 v3.1.0/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs= -github.com/ajg/form v0.0.0-20160802194845-cc2954064ec9/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY= -github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU= -github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY= -github.com/alcortesm/tgz v0.0.0-20161220082320-9c5fe88206d7 h1:uSoVVbwJiQipAclBbw+8quDsfcvFjOpI5iCf4p/cqCs= -github.com/alcortesm/tgz v0.0.0-20161220082320-9c5fe88206d7/go.mod h1:6zEj6s6u/ghQa61ZWa/C2Aw3RkjiTBOix7dkqa1VLIs= -github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d h1:UQZhZ2O0vMHr2cI+DC1Mbh0TJxzA3RcLoMsFw+aXw7E= -github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho= -github.com/andybalholm/brotli v0.0.0-20190621154722-5f990b63d2d6 h1:bZ28Hqta7TFAK3Q08CMvv8y3/8ATaEqv2nGoc6yff6c= -github.com/andybalholm/brotli v0.0.0-20190621154722-5f990b63d2d6/go.mod h1:+lx6/Aqd1kLJ1GQfkvOnaZ1WGmLpMpbprPuIOOZX30U= -github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239 h1:kFOfPq6dUM1hTo4JG6LR5AXSUEsOjtdm0kw0FtQtMJA= -github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c= -github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= -github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= +4d63.com/optional v0.2.0 h1:VtMa/Iy8Xn5JuIqJYwDScgBSBsZsKCwP7s35NiUB+8A= +4d63.com/optional v0.2.0/go.mod h1:DBA8tAdkYkYbvRq1lK3FyDBBzioAJzZzQPC6Vj+a3jk= +github.com/Masterminds/semver/v3 v3.3.1 h1:QtNSWtVZ3nBfk8mAOu/B6v7FMJ+NHTIgUPi7rj+4nv4= +github.com/Masterminds/semver/v3 v3.3.1/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b h1:mimo19zliBX/vSQ6PWWSL9lK8qwHozUj03+zLoEB8O0= +github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b/go.mod h1:fvzegU4vN3H1qMT+8wDmzjAcDONcgo2/SZ/TyfdUOFs= +github.com/andybalholm/brotli v1.0.1/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y= +github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA= +github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= +github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY= +github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0= github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ= github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= -github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY= +github.com/coreos/go-oidc/v3 v3.14.1 h1:9ePWwfdwC4QKRlCXsJGou56adA/owXczOzwKdOumLqk= +github.com/coreos/go-oidc/v3 v3.14.1/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dnaeon/go-vcr v1.0.1 h1:r8L/HqC0Hje5AXMu1ooW8oyQyOFv4GxqpL0nRP7SLLY= -github.com/dnaeon/go-vcr v1.0.1/go.mod h1:aBB1+wY4s93YsC3HHjMBMrwTj2R9FHDzUr9KyGc8n1E= -github.com/dsnet/compress v0.0.1 h1:PlZu0n3Tuv04TzpfPbrnI0HW/YwodEXDS+oPKahKF0Q= -github.com/dsnet/compress v0.0.1/go.mod h1:Aw8dCMJ7RioblQeTqt88akK31OvO8Dhf5JflhBbQEHo= +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/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI= +github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= +github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5 h1:iFaUwBSo5Svw6L7HYpRu/0lE3e0BaElwnNO1qkNQxBY= +github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5/go.mod h1:qssHWj60/X5sZFNxpG4HBPDHVqxNm4DfnCKgrbZOT+s= github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY= -github.com/dustinkirkland/golang-petname v0.0.0-20191129215211-8e5a1ed0cff0 h1:90Ly+6UfUypEF6vvvW5rQIv9opIL8CbmW9FT20LDQoY= -github.com/dustinkirkland/golang-petname v0.0.0-20191129215211-8e5a1ed0cff0/go.mod h1:V+Qd57rJe8gd4eiGzZyg4h54VLHmYVVw54iMnlAMrF8= -github.com/emirpasic/gods v1.12.0 h1:QAUIPSaCu4G+POclxeqb3F+WPpdKqFGlw36+yOzGlrg= -github.com/emirpasic/gods v1.12.0/go.mod h1:YfzfFFoVP/catgzJb4IKIqXjX78Ha8FMSDh3ymbK86o= -github.com/fastly/go-fastly/v3 v3.5.0 h1:NKLA21fapanz0s4Gun10XNWG34xkC5OwCgNc8IRMh5A= -github.com/fastly/go-fastly/v3 v3.5.0/go.mod h1:KOaCWsmkIKSASPzADl8PT/bTQIghOw/eEaxlHOu3jMA= +github.com/dustinkirkland/golang-petname v0.0.0-20240428194347-eebcea082ee0 h1:aYo8nnk3ojoQkP5iErif5Xxv0Mo0Ga/FR5+ffl/7+Nk= +github.com/dustinkirkland/golang-petname v0.0.0-20240428194347-eebcea082ee0/go.mod h1:8AuBTZBRSFqEYBPYULd+NN474/zZBLP+6WeT5S9xlAc= +github.com/fastly/go-fastly/v10 v10.2.0 h1:EICGnjskzXBgz4aUpK6mioCURMz5PUt1YoZySj3bzD0= +github.com/fastly/go-fastly/v10 v10.2.0/go.mod h1:UcROjNqDweQhC1f0N0qSeTIHJBZ/eCNTSZ/ZJe7rjCk= github.com/fastly/kingpin v2.1.12-0.20191105091915-95d230a53780+incompatible h1:FhrXlfhgGCS+uc6YwyiFUt04alnjpoX7vgDKJxS6Qbk= github.com/fastly/kingpin v2.1.12-0.20191105091915-95d230a53780+incompatible/go.mod h1:U8UynVoU1SQaqD2I4ZqgYd5lx3A1ipQYn4aSt2Y5h6c= -github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys= -github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= -github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc= -github.com/frankban/quicktest v1.5.0 h1:Tb4jWdSpdjKzTUicPnY61PZxKbDoGa7ABbrReT3gQVY= -github.com/frankban/quicktest v1.5.0/go.mod h1:jaStnuzAqU1AJdCO0l53JDCJrVDKcS03DbaAcR7Ks/o= -github.com/gliderlabs/ssh v0.2.2 h1:6zsha5zo/TWhRhwqCD3+EarCAgZ2yN28ipRnGPnwkI0= -github.com/gliderlabs/ssh v0.2.2/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0= -github.com/golang/gddo v0.0.0-20190419222130-af0f2af80721 h1:KRMr9A3qfbVM7iV/WcLY/rL5LICqwMHLhwRXKu99fXw= -github.com/golang/gddo v0.0.0-20190419222130-af0f2af80721/go.mod h1:xEhNfoBDX1hzLm2Nf80qUvZ2sVwoMZ8d6IE2SrsQfh4= -github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/snappy v0.0.1 h1:Qgr9rKW7uDUkrbSmQeiDsGa8SjGyCOGtuasMWwvp2P4= -github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= -github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.3.1 h1:Xye71clBPdm5HgqGwUkwhbynsUJZhDbS20FvLhQ2izg= -github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-github/v28 v28.1.1 h1:kORf5ekX5qwXO2mGzXXOjMe/g6ap8ahVe0sBEulhSxo= -github.com/google/go-github/v28 v28.1.1/go.mod h1:bsqJWQX05omyWVmc00nEUql9mhQyv38lDZ8kPZcQVoM= -github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk= -github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= -github.com/google/jsonapi v0.0.0-20201022225600-f822737867f6 h1:nVbdADVJLcaOp/CAR9xhaMCZrYn07HFFhUtM+dHsAIc= -github.com/google/jsonapi v0.0.0-20201022225600-f822737867f6/go.mod h1:XSx4m2SziAqk9DXY9nz659easTq4q6TyrpYd9tHSm0g= -github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= -github.com/hashicorp/go-cleanhttp v0.0.0-20170211013415-3573b8b52aa7/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= -github.com/hashicorp/go-cleanhttp v0.5.1 h1:dH3aiDG9Jvb5r5+bYHsikaOUIpcM0xvgMXVoDkXMzJM= -github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= -github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= -github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= -github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= +github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/go-jose/go-jose/v3 v3.0.4 h1:Wp5HA7bLQcKnf6YYao/4kpRpVMp/yf6+pJKV8WFSaNY= +github.com/go-jose/go-jose/v3 v3.0.4/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ= +github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE= +github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA= +github.com/golang/snappy v0.0.2/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= +github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/google/jsonapi v1.0.0 h1:qIGgO5Smu3yJmSs+QlvhQnrscdZfFhiV6S8ryJAglqU= +github.com/google/jsonapi v1.0.0/go.mod h1:YYHiRPJT8ARXGER8In9VuLv4qvLfDmA9ULQqptbLE4s= +github.com/hashicorp/cap v0.9.0 h1:B5IZT7VL1ruSCtVBXSIyWDpkAFiEZt4bQFk1e2WwCb0= +github.com/hashicorp/cap v0.9.0/go.mod h1:J00roe8PFFYXfedm3WcO6sGVaKeYElmNOuqfi8Uero4= +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 v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= +github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/kennygrant/sanitize v1.2.4 h1:gN25/otpP5vAsO2djbMhF/LQX6R7+O1TB4yv8NzpJ3o= github.com/kennygrant/sanitize v1.2.4/go.mod h1:LGsjYYtgxbetdg5owWB2mpgUL6e2nfw2eObZ0u0qvak= -github.com/kevinburke/ssh_config v0.0.0-20190725054713-01f96b0aa0cd h1:Coekwdh0v2wtGp9Gmz1Ze3eVRAWJMLokvN3QjdzCHLY= -github.com/kevinburke/ssh_config v0.0.0-20190725054713-01f96b0aa0cd/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= -github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A= -github.com/klauspost/compress v1.9.2 h1:LfVyl+ZlLlLDeQ/d2AqfGIIH4qEDu0Ed2S5GyhCWIWY= -github.com/klauspost/compress v1.9.2/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A= +github.com/klauspost/compress v1.11.4/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek= -github.com/klauspost/pgzip v1.2.1 h1:oIPZROsWuPHpOdMVWLuJZXwgjhrW8r1yEX8UqMyeNHM= -github.com/klauspost/pgzip v1.2.1/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs= -github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/klauspost/pgzip v1.2.5/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs= +github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU= +github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw= -github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= -github.com/mattn/go-colorable v0.1.7 h1:bQGKb3vps/j0E9GfJQ03JyhRuxsvdAanXlT9BTw3mdw= -github.com/mattn/go-colorable v0.1.7/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= -github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= -github.com/mholt/archiver v3.1.1+incompatible h1:1dCVxuqs0dJseYEhi5pl7MYPH9zDa1wBi7mF09cbNkU= -github.com/mholt/archiver v3.1.1+incompatible/go.mod h1:Dh2dOXnSdiLxRiPoVfIr/fI1TwETms9B8CTWfeh7ROU= -github.com/mholt/archiver/v3 v3.3.0 h1:vWjhY8SQp5yzM9P6OJ/eZEkmi3UAbRrxCq48MxjAzig= -github.com/mholt/archiver/v3 v3.3.0/go.mod h1:YnQtqsp+94Rwd0D/rk5cnLrxusUBUXg+08Ebtr1Mqao= -github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= -github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= -github.com/mitchellh/go-wordwrap v1.0.0 h1:6GlHJ/LTGMrIJbwgdqdl2eEH8o+Exx/0m8ir9Gns0u4= -github.com/mitchellh/go-wordwrap v1.0.0/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo= -github.com/mitchellh/mapstructure v0.0.0-20170523030023-d0303fe80992/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= -github.com/mitchellh/mapstructure v1.3.2 h1:mRS76wmkOn3KkKAyXDu42V+6ebnXWIztFSYGN7GeoRg= -github.com/mitchellh/mapstructure v1.3.2/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= -github.com/nicksnyder/go-i18n v1.10.1 h1:isfg77E/aCD7+0lD/D00ebR2MV5vgeQ276WYyDaCRQc= -github.com/nicksnyder/go-i18n v1.10.1/go.mod h1:e4Di5xjP9oTVrC6y3C7C0HoSYXjSbhh/dU0eUV32nB4= -github.com/nwaples/rardecode v1.0.0 h1:r7vGuS5akxOnR4JQSkko62RJ1ReCMXxQRPtxsiFMBOs= -github.com/nwaples/rardecode v1.0.0/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0= -github.com/pelletier/go-buffruneio v0.2.0/go.mod h1:JkE26KsDizTr40EUHkXVtNPvgGtbSNq5BcowyYOWdKo= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +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/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mholt/archiver/v3 v3.5.1 h1:rDjOBX9JSF5BvoJGvjqK479aL70qh9DIpZCl+k7Clwo= +github.com/mholt/archiver/v3 v3.5.1/go.mod h1:e3dqJ7H78uzsRSEACH1joayhuSyhnonssnDhppzS1L4= +github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= +github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= +github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c h1:cqn374mizHuIWj+OSJCajGr/phAmuMug9qIX3l9CflE= +github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/modocache/gover v0.0.0-20171022184752-b58185e213c5/go.mod h1:caMODM3PzxT8aQXRPkAt8xlV/e7d7w8GM5g0fa5F0D8= +github.com/nicksnyder/go-i18n v1.10.3 h1:0U60fnLBNrLBVt8vb8Q67yKNs+gykbQuLsIkiesJL+w= +github.com/nicksnyder/go-i18n v1.10.3/go.mod h1:hvLG5HTlZ4UfSuVLSRuX7JRUomIaoKQM19hm6f+no7o= +github.com/nwaples/rardecode v1.1.0/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0= +github.com/nwaples/rardecode v1.1.3 h1:cWCaZwfM5H7nAD6PyEdcVnczzV8i/JtotnyW/dD9lEc= +github.com/nwaples/rardecode v1.1.3/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0= +github.com/otiai10/copy v1.14.1 h1:5/7E6qsUMBaH5AnQ0sSLzzTg1oTECmcCmT6lvF45Na8= +github.com/otiai10/copy v1.14.1/go.mod h1:oQwrEDDOci3IM8dJF0d8+jnbfPDllW6vUjNc3DoZm9I= +github.com/otiai10/mint v1.6.3 h1:87qsV/aw1F5as1eH1zS/yqHY85ANKVMgkDrf9rcxbQs= +github.com/otiai10/mint v1.6.3/go.mod h1:MJm72SBthJjz8qhefc4z1PYEieWmy8Bku7CjcAqyUSM= github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= -github.com/pelletier/go-toml v1.8.1 h1:1Nf83orprkJyknT6h7zbuEGUEjcyVlCxSUGTENmNCRM= -github.com/pelletier/go-toml v1.8.1/go.mod h1:T2/BmBdy8dvIRq1a/8aqjN41wvWlN4lrapLU/GW4pbc= -github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY= -github.com/pierrec/lz4 v2.3.0+incompatible h1:CZzRn4Ut9GbUkHlQ7jqBXeZQV41ZSKWFc302ZU6lUTk= -github.com/pierrec/lz4 v2.3.0+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY= -github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= -github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= +github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= +github.com/peterhellberg/link v1.2.0 h1:UA5pg3Gp/E0F2WdX7GERiNrPQrM1K6CVJUUWfHa4t6c= +github.com/peterhellberg/link v1.2.0/go.mod h1:gYfAh+oJgQu2SrZHg5hROVRQe1ICoK0/HHJTcE0edxc= +github.com/pierrec/lz4/v4 v4.1.2/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU= +github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +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.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 h1:OkMGxebDjyw0ULyrTYWeN0UNCCkmCWfjPnIA2W6oviI= +github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06/go.mod h1:+ePHsJ1keEjQtpvf9HHw0f4ZeJ0TLRsxhunSI2hYJSs= github.com/segmentio/textio v1.2.0 h1:Ug4IkV3kh72juJbG8azoSBlgebIbUUxVNrfFcKHfTSQ= github.com/segmentio/textio v1.2.0/go.mod h1:+Rb7v0YVODP+tK5F7FD9TCkV7gOYx9IgLHWiqtvY8ag= -github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ= -github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= -github.com/src-d/gcfg v1.4.0 h1:xXbNR5AlLSA315x2UO+fTSSAXCDf+Ar38/6oyGbDKQ4= -github.com/src-d/gcfg v1.4.0/go.mod h1:p/UMsR43ujA89BJY9duynAwIpvqEujIH/jFlfL7jWoI= +github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EEf9cgbU6AtGPK4CTG3Zf6CKMNqf0MHTggAUA= +github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= -github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/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/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/theckman/yacspin v0.13.12 h1:CdZ57+n0U6JMuh2xqjnjRq5Haj6v1ner2djtLQRzJr4= +github.com/theckman/yacspin v0.13.12/go.mod h1:Rd2+oG2LmQi5f3zC3yeZAOl245z8QOvrH4OPOJNZxLg= github.com/tomnomnom/linkheader v0.0.0-20180905144013-02ca5825eb80 h1:nrZ3ySNYwJbSpD6ce9duiP+QkD3JuLCcWkdaehUS/3Y= github.com/tomnomnom/linkheader v0.0.0-20180905144013-02ca5825eb80/go.mod h1:iFyPdL66DjUD96XmzVL3ZntbzcflLnznH0fr99w5VqE= -github.com/ulikunitz/xz v0.5.6 h1:jGHAfXawEGZQ3blwU5wnWKQJvAraT7Ftq9EXjnXYgt8= -github.com/ulikunitz/xz v0.5.6/go.mod h1:2bypXElzHzzJZwzH67Y6wb67pO62Rzfn7BSiF4ABRW8= -github.com/xanzy/ssh-agent v0.2.1 h1:TCbipTQL2JiiCprBWx9frJ2eJlCYT00NmctrHxVAr70= -github.com/xanzy/ssh-agent v0.2.1/go.mod h1:mLlQY/MoOhWBj+gOGMQkOeiEvkx+8pJSI+0Bx9h2kr4= +github.com/ulikunitz/xz v0.5.8/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= +github.com/ulikunitz/xz v0.5.9/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= +github.com/ulikunitz/xz v0.5.12 h1:37Nm15o69RwBkXM0J6A5OlE67RZTfzUxTj8fB3dfcsc= +github.com/ulikunitz/xz v0.5.12/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 h1:nIPpBwaJSVYIxUFsDv3M8ofmx9yWTog9BfvIu0q41lo= github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8/go.mod h1:HUYIGzjTL3rfEspMxjDjgmT5uz5wzYJKVo23qUhYTos= -github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -golang.org/x/crypto v0.0.0-20190219172222-a4c6cb3142f2/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +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-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 h1:psW17arqaxU48Z5kZ0CQnkZWQJsqcURM6tKiBApRjXI= -golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= -golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= +golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= +golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= +golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= +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/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= +golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20201021035429-f5854403a974 h1:IX6qOQeG5uLjB/hjjwjedwfjND0hgjPMMyO1RoIXQNI= -golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= -golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +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.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= +golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= +golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= +golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/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.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= +golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190221075227-b4e8571b14e0/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f h1:+Nyd8tzPX9R7BWHguqsrbFdRx3WQ/1ib8I44HXV5yTA= -golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/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.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.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +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.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= +golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= -golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.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/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= +golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190729092621-ff9f1409240a/go.mod h1:jcCCGcm9btYwXyDqrUWc6MKQKKGJCWEQ3AfLSRIbEuI= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20200624163319-25775e59acb7/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +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-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= -gopkg.in/src-d/go-billy.v4 v4.3.2 h1:0SQA1pRztfTFx2miS8sA97XvooFeNOmvUenF4o0EcVg= -gopkg.in/src-d/go-billy.v4 v4.3.2/go.mod h1:nDjArDMp+XMs1aFAESLRjfGSgfvoYN0hDfzEk0GjC98= -gopkg.in/src-d/go-git-fixtures.v3 v3.5.0 h1:ivZFOIltbce2Mo8IjzUHAFoq/IylO9WHhNOAJK+LsJg= -gopkg.in/src-d/go-git-fixtures.v3 v3.5.0/go.mod h1:dLBcvytrw/TYZsNTWCnkNF2DSIlzWYqTe3rJR56Ac7g= -gopkg.in/src-d/go-git.v4 v4.13.1 h1:SRtFyV8Kxc0UP7aCHcijOMQGPxHSmMOPrzulQWolkYE= -gopkg.in/src-d/go-git.v4 v4.13.1/go.mod h1:nx5NYcxdKxq5fpltdHnPa2Exj4Sx0EclMWZQbYDu2z8= -gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= -gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= -gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU= -gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= +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/npm/@fastly/cli/.gitignore b/npm/@fastly/cli/.gitignore new file mode 100644 index 000000000..e5d90d362 --- /dev/null +++ b/npm/@fastly/cli/.gitignore @@ -0,0 +1,3 @@ +LICENSE +README.md +SECURITY.md diff --git a/npm/@fastly/cli/fastly.js b/npm/@fastly/cli/fastly.js new file mode 100644 index 000000000..b16cb66eb --- /dev/null +++ b/npm/@fastly/cli/fastly.js @@ -0,0 +1,32 @@ +#!/usr/bin/env node +import { execFileSync } from "node:child_process"; + +import { pkgForCurrentPlatform } from "./package-helpers.js"; + +const pkg = pkgForCurrentPlatform(); + +let location; +try { + // Check for the binary package from our "optionalDependencies". This + // package should have been installed alongside this package at install time. + location = (await import(pkg)).default; +} catch (e) { + throw new Error(`The package "${pkg}" could not be found, and is needed by @fastly/cli. + Either the package is missing or the platform/architecture you are using is not supported. + If you are installing @fastly/cli with npm, make sure that you don't specify the + "--no-optional" flag. The "optionalDependencies" package.json feature is used + by @fastly/cli to install the correct binary executable for your current platform. + If your platform is not supported, you can open an issue at https://github.com/fastly/cli/issues`); +} + +try { + execFileSync(location, process.argv.slice(2), { stdio: "inherit" }); +} catch(err) { + if (err.code) { + // Spawning child process failed + throw err; + } + if (err.status != null) { + process.exitCode = err.status; + } +} diff --git a/npm/@fastly/cli/index.d.ts b/npm/@fastly/cli/index.d.ts new file mode 100644 index 000000000..6c05b1271 --- /dev/null +++ b/npm/@fastly/cli/index.d.ts @@ -0,0 +1,4 @@ +declare module '@fastly/cli' { + const location: string; + export default location; +} diff --git a/npm/@fastly/cli/index.js b/npm/@fastly/cli/index.js new file mode 100644 index 000000000..ab5876e73 --- /dev/null +++ b/npm/@fastly/cli/index.js @@ -0,0 +1,19 @@ +import { pkgForCurrentPlatform } from "./package-helpers.js"; + +const pkg = pkgForCurrentPlatform(); + +let location; +try { + // Check for the binary package from our "optionalDependencies". This + // package should have been installed alongside this package at install time. + location = (await import(pkg)).default; +} catch (e) { + throw new Error(`The package "${pkg}" could not be found, and is needed by @fastly/cli. + Either the package is missing or the platform/architecture you are using is not supported. + If you are installing @fastly/cli with npm, make sure that you don't specify the + "--no-optional" flag. The "optionalDependencies" package.json feature is used + by @fastly/cli to install the correct binary executable for your current platform. + If your platform is not supported, you can open an issue at https://github.com/fastly/cli/issues`); +} + +export default location; diff --git a/npm/@fastly/cli/package-helpers.js b/npm/@fastly/cli/package-helpers.js new file mode 100644 index 000000000..054bf89e7 --- /dev/null +++ b/npm/@fastly/cli/package-helpers.js @@ -0,0 +1,5 @@ +import { platform, arch } from "node:process"; + +export function pkgForCurrentPlatform() { + return `@fastly/cli-${platform}-${arch}`; +} diff --git a/npm/@fastly/cli/package-lock.json b/npm/@fastly/cli/package-lock.json new file mode 100644 index 000000000..b8120abbe --- /dev/null +++ b/npm/@fastly/cli/package-lock.json @@ -0,0 +1,545 @@ +{ + "name": "@fastly/cli", + "version": "10.12.3", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@fastly/cli", + "version": "10.12.3", + "license": "Apache-2.0", + "bin": { + "fastly": "fastly.js" + }, + "devDependencies": { + "decompress": "^4.2.1", + "decompress-targz": "^4.1.1" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/bl": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/bl/-/bl-1.2.3.tgz", + "integrity": "sha512-pvcNpa0UU69UT341rO6AYy4FVAIkUHuZXRIWbq+zHnsVcRzDDjIAhGuuYoi0d//cwIwtt4pkpKycWEfjdV+vww==", + "dev": true, + "dependencies": { + "readable-stream": "^2.3.5", + "safe-buffer": "^5.1.1" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-alloc": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/buffer-alloc/-/buffer-alloc-1.2.0.tgz", + "integrity": "sha512-CFsHQgjtW1UChdXgbyJGtnm+O/uLQeZdtbDo8mfUgYXCHSM1wgrVxXm6bSyrUuErEb+4sYVGCzASBRot7zyrow==", + "dev": true, + "dependencies": { + "buffer-alloc-unsafe": "^1.1.0", + "buffer-fill": "^1.0.0" + } + }, + "node_modules/buffer-alloc-unsafe": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/buffer-alloc-unsafe/-/buffer-alloc-unsafe-1.1.0.tgz", + "integrity": "sha512-TEM2iMIEQdJ2yjPJoSIsldnleVaAk1oW3DBVUykyOLsEsFmEc9kn+SFFPz+gl54KQNxlDnAwCXosOS9Okx2xAg==", + "dev": true + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, + "engines": { + "node": "*" + } + }, + "node_modules/buffer-fill": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/buffer-fill/-/buffer-fill-1.0.0.tgz", + "integrity": "sha512-T7zexNBwiiaCOGDg9xNX9PBmjrubblRkENuptryuI64URkXDFum9il/JGL8Lm8wYfAXpredVXXZz7eMHilimiQ==", + "dev": true + }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "dev": true + }, + "node_modules/decompress": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/decompress/-/decompress-4.2.1.tgz", + "integrity": "sha512-e48kc2IjU+2Zw8cTb6VZcJQ3lgVbS4uuB1TfCHbiZIP/haNXm+SVyhu+87jts5/3ROpd82GSVCoNs/z8l4ZOaQ==", + "dev": true, + "dependencies": { + "decompress-tar": "^4.0.0", + "decompress-tarbz2": "^4.0.0", + "decompress-targz": "^4.0.0", + "decompress-unzip": "^4.0.1", + "graceful-fs": "^4.1.10", + "make-dir": "^1.0.0", + "pify": "^2.3.0", + "strip-dirs": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/decompress-tar": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/decompress-tar/-/decompress-tar-4.1.1.tgz", + "integrity": "sha512-JdJMaCrGpB5fESVyxwpCx4Jdj2AagLmv3y58Qy4GE6HMVjWz1FeVQk1Ct4Kye7PftcdOo/7U7UKzYBJgqnGeUQ==", + "dev": true, + "dependencies": { + "file-type": "^5.2.0", + "is-stream": "^1.1.0", + "tar-stream": "^1.5.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/decompress-tarbz2": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/decompress-tarbz2/-/decompress-tarbz2-4.1.1.tgz", + "integrity": "sha512-s88xLzf1r81ICXLAVQVzaN6ZmX4A6U4z2nMbOwobxkLoIIfjVMBg7TeguTUXkKeXni795B6y5rnvDw7rxhAq9A==", + "dev": true, + "dependencies": { + "decompress-tar": "^4.1.0", + "file-type": "^6.1.0", + "is-stream": "^1.1.0", + "seek-bzip": "^1.0.5", + "unbzip2-stream": "^1.0.9" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/decompress-tarbz2/node_modules/file-type": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-6.2.0.tgz", + "integrity": "sha512-YPcTBDV+2Tm0VqjybVd32MHdlEGAtuxS3VAYsumFokDSMG+ROT5wawGlnHDoz7bfMcMDt9hxuXvXwoKUx2fkOg==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/decompress-targz": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/decompress-targz/-/decompress-targz-4.1.1.tgz", + "integrity": "sha512-4z81Znfr6chWnRDNfFNqLwPvm4db3WuZkqV+UgXQzSngG3CEKdBkw5jrv3axjjL96glyiiKjsxJG3X6WBZwX3w==", + "dev": true, + "dependencies": { + "decompress-tar": "^4.1.1", + "file-type": "^5.2.0", + "is-stream": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/decompress-unzip": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/decompress-unzip/-/decompress-unzip-4.0.1.tgz", + "integrity": "sha512-1fqeluvxgnn86MOh66u8FjbtJpAFv5wgCT9Iw8rcBqQcCo5tO8eiJw7NNTrvt9n4CRBVq7CstiS922oPgyGLrw==", + "dev": true, + "dependencies": { + "file-type": "^3.8.0", + "get-stream": "^2.2.0", + "pify": "^2.3.0", + "yauzl": "^2.4.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/decompress-unzip/node_modules/file-type": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-3.9.0.tgz", + "integrity": "sha512-RLoqTXE8/vPmMuTI88DAzhMYC99I8BWv7zYP4A1puo5HIjEJ5EX48ighy4ZyKMG9EDXxBgW6e++cn7d1xuFghA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", + "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", + "dev": true, + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dev": true, + "dependencies": { + "pend": "~1.2.0" + } + }, + "node_modules/file-type": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-5.2.0.tgz", + "integrity": "sha512-Iq1nJ6D2+yIO4c8HHg4fyVb8mAJieo1Oloy1mLLaB2PvezNedhBVm+QU7g0qM42aiMbRXTxKKwGD17rjKNJYVQ==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "dev": true + }, + "node_modules/get-stream": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-2.3.1.tgz", + "integrity": "sha512-AUGhbbemXxrZJRD5cDvKtQxLuYaIbNtDTK8YqupCI393Q2KSTreEsLUN3ZxAWFGiKTzL6nKuzfcIvieflUX9qA==", + "dev": true, + "dependencies": { + "object-assign": "^4.0.1", + "pinkie-promise": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true + }, + "node_modules/is-natural-number": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-natural-number/-/is-natural-number-4.0.1.tgz", + "integrity": "sha512-Y4LTamMe0DDQIIAlaer9eKebAlDSV6huy+TWhJVPlzZh2o4tRP5SQWFlLn5N0To4mDD22/qdOq+veo1cSISLgQ==", + "dev": true + }, + "node_modules/is-stream": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", + "integrity": "sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true + }, + "node_modules/make-dir": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-1.3.0.tgz", + "integrity": "sha512-2w31R7SJtieJJnQtGc7RVL2StM2vGYVfqUOvUDxH6bC6aJTxPxTF0GnIgCyu7tjockiUWAYQRbxa7vKn34s5sQ==", + "dev": true, + "dependencies": { + "pify": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/make-dir/node_modules/pify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", + "integrity": "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pinkie": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/pinkie/-/pinkie-2.0.4.tgz", + "integrity": "sha512-MnUuEycAemtSaeFSjXKW/aroV7akBbY+Sv+RkyqFjgAe73F+MR0TBWKBRDkmfWq/HiFmdavfZ1G7h4SPZXaCSg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pinkie-promise": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pinkie-promise/-/pinkie-promise-2.0.1.tgz", + "integrity": "sha512-0Gni6D4UcLTbv9c57DfxDGdr41XfgUjqWZu492f0cIGr16zDU06BWP/RAEvOuo7CQ0CNjHaLlM59YJJFm3NWlw==", + "dev": true, + "dependencies": { + "pinkie": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/seek-bzip": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/seek-bzip/-/seek-bzip-1.0.6.tgz", + "integrity": "sha512-e1QtP3YL5tWww8uKaOCQ18UxIT2laNBXHjV/S2WYCiK4udiv8lkG89KRIoCjUagnAmCBurjF4zEVX2ByBbnCjQ==", + "dev": true, + "dependencies": { + "commander": "^2.8.1" + }, + "bin": { + "seek-bunzip": "bin/seek-bunzip", + "seek-table": "bin/seek-bzip-table" + } + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true + }, + "node_modules/strip-dirs": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/strip-dirs/-/strip-dirs-2.1.0.tgz", + "integrity": "sha512-JOCxOeKLm2CAS73y/U4ZeZPTkE+gNVCzKt7Eox84Iej1LT/2pTWYpZKJuxwQpvX1LiZb1xokNR7RLfuBAa7T3g==", + "dev": true, + "dependencies": { + "is-natural-number": "^4.0.1" + } + }, + "node_modules/tar-stream": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-1.6.2.tgz", + "integrity": "sha512-rzS0heiNf8Xn7/mpdSVVSMAWAoy9bfb1WOTYC78Z0UQKeKa/CWS8FOq0lKGNa8DWKAn9gxjCvMLYc5PGXYlK2A==", + "dev": true, + "dependencies": { + "bl": "^1.0.0", + "buffer-alloc": "^1.2.0", + "end-of-stream": "^1.0.0", + "fs-constants": "^1.0.0", + "readable-stream": "^2.3.0", + "to-buffer": "^1.1.1", + "xtend": "^4.0.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", + "dev": true + }, + "node_modules/to-buffer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.1.1.tgz", + "integrity": "sha512-lx9B5iv7msuFYE3dytT+KE5tap+rNYw+K4jVkb9R/asAb+pbBSM17jtunHplhBe6RRJdZx3Pn2Jph24O32mOVg==", + "dev": true + }, + "node_modules/unbzip2-stream": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/unbzip2-stream/-/unbzip2-stream-1.4.3.tgz", + "integrity": "sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg==", + "dev": true, + "dependencies": { + "buffer": "^5.2.1", + "through": "^2.3.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "dev": true, + "engines": { + "node": ">=0.4" + } + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dev": true, + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + } + } +} diff --git a/npm/@fastly/cli/package.json b/npm/@fastly/cli/package.json new file mode 100644 index 000000000..77cb730c0 --- /dev/null +++ b/npm/@fastly/cli/package.json @@ -0,0 +1,42 @@ +{ + "name": "@fastly/cli", + "version": "10.12.3", + "description": "Build, deploy and configure Fastly services from your terminal", + "type": "module", + "scripts": { + "prepack": "cp ../../../README.md ../../../LICENSE ../../../SECURITY.md .", + "version": "node ./update.js $npm_package_version" + }, + "devDependencies": { + "decompress": "^4.2.1", + "decompress-targz": "^4.1.1" + }, + "main": "index.js", + "types": "index.d.ts", + "engines": { + "node": ">=16" + }, + "bin": { + "fastly": "fastly.js" + }, + "optionalDependencies": {}, + "files": [ + "index.js", + "fastly.js", + "package-helpers.js", + "update.js", + "index.d.ts", + "README.md", + "LICENSE", + "SECURITY.md" + ], + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "git+https://github.com/fastly/cli.git" + }, + "bugs": { + "url": "https://github.com/fastly/cli/issues" + }, + "homepage": "https://github.com/fastly/cli#readme" +} diff --git a/npm/@fastly/cli/update.js b/npm/@fastly/cli/update.js new file mode 100644 index 000000000..c69a4a7f0 --- /dev/null +++ b/npm/@fastly/cli/update.js @@ -0,0 +1,188 @@ +#!/usr/bin/env node + +import { fileURLToPath } from "node:url"; +import { dirname, join, parse } from "node:path"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import decompress from "decompress"; +import decompressTargz from "decompress-targz"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const input = process.argv.slice(2).at(0); +const tag = input ? `v${input}` : "dev"; + +let packages = [ + { + releaseAsset: `fastly_${tag}_darwin-arm64.tar.gz`, + binaryAsset: "fastly", + description: "The macOS (M-series) binary for the Fastly CLI", + os: "darwin", + cpu: "arm64", + }, + { + releaseAsset: `fastly_${tag}_darwin-amd64.tar.gz`, + binaryAsset: "fastly", + description: "The macOS (Intel) binary for the Fastly CLI", + os: "darwin", + cpu: "x64", + }, + { + releaseAsset: `fastly_${tag}_linux-arm64.tar.gz`, + binaryAsset: "fastly", + description: "The Linux (arm64) binary for the Fastly CLI", + os: "linux", + cpu: "arm64", + }, + { + releaseAsset: `fastly_${tag}_linux-amd64.tar.gz`, + binaryAsset: "fastly", + description: "The Linux (64-bit) binary for the Fastly CLI", + os: "linux", + cpu: "x64", + }, + { + releaseAsset: `fastly_${tag}_linux-386.tar.gz`, + binaryAsset: "fastly", + description: "The Linux (32-bit) binary for the Fastly CLI", + os: "linux", + cpu: "x32", + }, + { + releaseAsset: `fastly_${tag}_windows-arm64.tar.gz`, + binaryAsset: "fastly.exe", + description: "The Windows (arm64) binary for the Fastly CLI", + os: "win32", + cpu: "arm64", + }, + { + releaseAsset: `fastly_${tag}_windows-amd64.tar.gz`, + binaryAsset: "fastly.exe", + description: "The Windows (64-bit) binary for the Fastly CLI", + os: "win32", + cpu: "x64", + }, + { + releaseAsset: `fastly_${tag}_windows-386.tar.gz`, + binaryAsset: "fastly.exe", + description: "The Windows (32-bit) binary for the Fastly CLI", + os: "win32", + cpu: "x32", + }, +]; + +let response = await fetch( + `https://api.github.com/repos/fastly/cli/releases/tags/${tag}` +); +if (!response.ok) { + console.error( + '%s %o', + `Response from https://api.github.com/repos/fastly/cli/releases/tags/${tag} was not ok`, + response + ); + console.error(await response.text()); + process.exit(1); +} +response = await response.json(); +const id = response.id; +let assets = await fetch( + `https://api.github.com/repos/fastly/cli/releases/${id}/assets` +); +if (!assets.ok) { + console.error( + '%s %o', + `Response from https://api.github.com/repos/fastly/cli/releases/${id}/assets was not ok`, + assets + ); + console.error(await assets.text()); + process.exit(1); +} +assets = await assets.json(); + +let generatedPackages = []; + +for (const info of packages) { + const packageName = `cli-${info.os}-${info.cpu}`; + const asset = assets.find((asset) => asset.name === info.releaseAsset); + if (!asset) { + console.error( + `Can't find an asset named ${info.releaseAsset} for the release https://github.com/fastly/cli/releases/tag/${tag}` + ); + process.exit(1); + } + const packageDirectory = join(__dirname, "../", packageName.split("/").pop()); + await mkdir(packageDirectory, { recursive: true }); + await writeFile( + join(packageDirectory, "package.json"), + packageJson(packageName, tag, info.description, info.os, info.cpu, info.binaryAsset) + ); + await writeFile( + join(packageDirectory, "index.js"), + indexJs(info.binaryAsset) + ); + generatedPackages.push(packageName); + const browser_download_url = asset.browser_download_url; + const archive = await fetch(browser_download_url); + if (!archive.ok) { + console.error( + '%s %o', + `Response from ${browser_download_url} was not ok`, + archive + ); + console.error(await response.text()); + process.exit(1); + } + let buf = await archive.arrayBuffer(); + + await decompress(Buffer.from(buf), packageDirectory, { + // Remove the leading directory from the extracted file. + strip: 1, + plugins: [decompressTargz()], + // Only extract the binary file and nothing else + filter: (file) => parse(file.path).base === info.binaryAsset, + }); +} + +// Generate `optionalDependencies` section in the root package.json +const rootPackageJsonPath = join(__dirname, "./package.json"); +let rootPackageJson = await readFile(rootPackageJsonPath, "utf8"); +rootPackageJson = JSON.parse(rootPackageJson); +rootPackageJson["optionalDependencies"] = generatedPackages.reduce( + (acc, packageName) => { + acc[`@fastly/${packageName}`] = `=${tag.substring(1)}`; + return acc; + }, + {} +); +await writeFile(rootPackageJsonPath, JSON.stringify(rootPackageJson, null, 4)); + +function indexJs(binaryAsset) { + return ` +import { fileURLToPath } from 'node:url' +import { dirname, join } from 'node:path' +const __dirname = dirname(fileURLToPath(import.meta.url)) +let location = join(__dirname, '${binaryAsset}') +export default location +`; +} +function packageJson(name, version, description, os, cpu, binaryAsset) { + version = version.startsWith("v") ? version.replace("v", "") : version; + return JSON.stringify( + { + name: `@fastly/${name}`, + bin: { + [name]: `${binaryAsset}`, + }, + scripts: { + }, + type: "module", + version, + main: "index.js", + description, + license: "Apache-2.0", + preferUnplugged: false, + os: [os], + cpu: [cpu], + }, + null, + 4 + ); +} diff --git a/pkg/api/interface.go b/pkg/api/interface.go index f46d827df..012d441fa 100644 --- a/pkg/api/interface.go +++ b/pkg/api/interface.go @@ -1,9 +1,10 @@ package api import ( + "crypto/ed25519" "net/http" - "github.com/fastly/go-fastly/v3/fastly" + "github.com/fastly/go-fastly/v10/fastly" ) // HTTPClient models a concrete http.Client. It's a consumer contract for some @@ -16,14 +17,12 @@ type HTTPClient interface { // Interface models the methods of the Fastly API client that we use. // It exists to allow for easier testing, in combination with Mock. -// -// TODO(integralist): -// There are missing methods such as GetVersion from this list so review in -// future the missing features in CLI and implement here. type Interface interface { - GetTokenSelf() (*fastly.Token, error) + AllIPs() (v4, v6 fastly.IPAddrs, err error) + AllDatacenters() (datacenters []fastly.Datacenter, err error) CreateService(*fastly.CreateServiceInput) (*fastly.Service, error) + GetServices(*fastly.GetServicesInput) *fastly.ListPaginator[fastly.Service] ListServices(*fastly.ListServicesInput) ([]*fastly.Service, error) GetService(*fastly.GetServiceInput) (*fastly.Service, error) GetServiceDetails(*fastly.GetServiceInput) (*fastly.ServiceDetail, error) @@ -33,6 +32,7 @@ type Interface interface { CloneVersion(*fastly.CloneVersionInput) (*fastly.Version, error) ListVersions(*fastly.ListVersionsInput) ([]*fastly.Version, error) + GetVersion(*fastly.GetVersionInput) (*fastly.Version, error) UpdateVersion(*fastly.UpdateVersionInput) (*fastly.Version, error) ActivateVersion(*fastly.ActivateVersionInput) (*fastly.Version, error) DeactivateVersion(*fastly.DeactivateVersionInput) (*fastly.Version, error) @@ -44,6 +44,8 @@ type Interface interface { GetDomain(*fastly.GetDomainInput) (*fastly.Domain, error) UpdateDomain(*fastly.UpdateDomainInput) (*fastly.Domain, error) DeleteDomain(*fastly.DeleteDomainInput) error + ValidateDomain(i *fastly.ValidateDomainInput) (*fastly.DomainValidationResult, error) + ValidateAllDomains(i *fastly.ValidateAllDomainsInput) (results []*fastly.DomainValidationResult, err error) CreateBackend(*fastly.CreateBackendInput) (*fastly.Backend, error) ListBackends(*fastly.ListBackendsInput) ([]*fastly.Backend, error) @@ -66,6 +68,7 @@ type Interface interface { ListDictionaries(*fastly.ListDictionariesInput) ([]*fastly.Dictionary, error) UpdateDictionary(*fastly.UpdateDictionaryInput) (*fastly.Dictionary, error) + GetDictionaryItems(*fastly.GetDictionaryItemsInput) *fastly.ListPaginator[fastly.DictionaryItem] ListDictionaryItems(*fastly.ListDictionaryItemsInput) ([]*fastly.DictionaryItem, error) GetDictionaryItem(*fastly.GetDictionaryItemInput) (*fastly.DictionaryItem, error) CreateDictionaryItem(*fastly.CreateDictionaryItemInput) (*fastly.DictionaryItem, error) @@ -123,6 +126,12 @@ type Interface interface { UpdateGCS(*fastly.UpdateGCSInput) (*fastly.GCS, error) DeleteGCS(*fastly.DeleteGCSInput) error + CreateGrafanaCloudLogs(*fastly.CreateGrafanaCloudLogsInput) (*fastly.GrafanaCloudLogs, error) + ListGrafanaCloudLogs(*fastly.ListGrafanaCloudLogsInput) ([]*fastly.GrafanaCloudLogs, error) + GetGrafanaCloudLogs(*fastly.GetGrafanaCloudLogsInput) (*fastly.GrafanaCloudLogs, error) + UpdateGrafanaCloudLogs(*fastly.UpdateGrafanaCloudLogsInput) (*fastly.GrafanaCloudLogs, error) + DeleteGrafanaCloudLogs(*fastly.DeleteGrafanaCloudLogsInput) error + CreateFTP(*fastly.CreateFTPInput) (*fastly.FTP, error) ListFTPs(*fastly.ListFTPsInput) ([]*fastly.FTP, error) GetFTP(*fastly.GetFTPInput) (*fastly.FTP, error) @@ -225,17 +234,189 @@ type Interface interface { UpdateOpenstack(*fastly.UpdateOpenstackInput) (*fastly.Openstack, error) DeleteOpenstack(*fastly.DeleteOpenstackInput) error - GetUser(*fastly.GetUserInput) (*fastly.User, error) - GetRegions() (*fastly.RegionsResponse, error) - GetStatsJSON(*fastly.GetStatsInput, interface{}) error + GetStatsJSON(*fastly.GetStatsInput, any) error CreateManagedLogging(*fastly.CreateManagedLoggingInput) (*fastly.ManagedLogging, error) + + CreateVCL(*fastly.CreateVCLInput) (*fastly.VCL, error) + ListVCLs(*fastly.ListVCLsInput) ([]*fastly.VCL, error) + GetVCL(*fastly.GetVCLInput) (*fastly.VCL, error) + UpdateVCL(*fastly.UpdateVCLInput) (*fastly.VCL, error) + DeleteVCL(*fastly.DeleteVCLInput) error + + CreateSnippet(i *fastly.CreateSnippetInput) (*fastly.Snippet, error) + ListSnippets(i *fastly.ListSnippetsInput) ([]*fastly.Snippet, error) + GetSnippet(i *fastly.GetSnippetInput) (*fastly.Snippet, error) + GetDynamicSnippet(i *fastly.GetDynamicSnippetInput) (*fastly.DynamicSnippet, error) + UpdateSnippet(i *fastly.UpdateSnippetInput) (*fastly.Snippet, error) + UpdateDynamicSnippet(i *fastly.UpdateDynamicSnippetInput) (*fastly.DynamicSnippet, error) + DeleteSnippet(i *fastly.DeleteSnippetInput) error + + Purge(i *fastly.PurgeInput) (*fastly.Purge, error) + PurgeKey(i *fastly.PurgeKeyInput) (*fastly.Purge, error) + PurgeKeys(i *fastly.PurgeKeysInput) (map[string]string, error) + PurgeAll(i *fastly.PurgeAllInput) (*fastly.Purge, error) + + CreateACL(i *fastly.CreateACLInput) (*fastly.ACL, error) + DeleteACL(i *fastly.DeleteACLInput) error + GetACL(i *fastly.GetACLInput) (*fastly.ACL, error) + ListACLs(i *fastly.ListACLsInput) ([]*fastly.ACL, error) + UpdateACL(i *fastly.UpdateACLInput) (*fastly.ACL, error) + + CreateACLEntry(i *fastly.CreateACLEntryInput) (*fastly.ACLEntry, error) + DeleteACLEntry(i *fastly.DeleteACLEntryInput) error + GetACLEntry(i *fastly.GetACLEntryInput) (*fastly.ACLEntry, error) + GetACLEntries(*fastly.GetACLEntriesInput) *fastly.ListPaginator[fastly.ACLEntry] + ListACLEntries(i *fastly.ListACLEntriesInput) ([]*fastly.ACLEntry, error) + UpdateACLEntry(i *fastly.UpdateACLEntryInput) (*fastly.ACLEntry, error) + BatchModifyACLEntries(i *fastly.BatchModifyACLEntriesInput) error + + CreateNewRelic(i *fastly.CreateNewRelicInput) (*fastly.NewRelic, error) + DeleteNewRelic(i *fastly.DeleteNewRelicInput) error + GetNewRelic(i *fastly.GetNewRelicInput) (*fastly.NewRelic, error) + ListNewRelic(i *fastly.ListNewRelicInput) ([]*fastly.NewRelic, error) + UpdateNewRelic(i *fastly.UpdateNewRelicInput) (*fastly.NewRelic, error) + + CreateNewRelicOTLP(i *fastly.CreateNewRelicOTLPInput) (*fastly.NewRelicOTLP, error) + DeleteNewRelicOTLP(i *fastly.DeleteNewRelicOTLPInput) error + GetNewRelicOTLP(i *fastly.GetNewRelicOTLPInput) (*fastly.NewRelicOTLP, error) + ListNewRelicOTLP(i *fastly.ListNewRelicOTLPInput) ([]*fastly.NewRelicOTLP, error) + UpdateNewRelicOTLP(i *fastly.UpdateNewRelicOTLPInput) (*fastly.NewRelicOTLP, error) + + CreateUser(i *fastly.CreateUserInput) (*fastly.User, error) + DeleteUser(i *fastly.DeleteUserInput) error + GetCurrentUser() (*fastly.User, error) + GetUser(i *fastly.GetUserInput) (*fastly.User, error) + ListCustomerUsers(i *fastly.ListCustomerUsersInput) ([]*fastly.User, error) + UpdateUser(i *fastly.UpdateUserInput) (*fastly.User, error) + ResetUserPassword(i *fastly.ResetUserPasswordInput) error + + BatchDeleteTokens(i *fastly.BatchDeleteTokensInput) error + CreateToken(i *fastly.CreateTokenInput) (*fastly.Token, error) + DeleteToken(i *fastly.DeleteTokenInput) error + DeleteTokenSelf() error + GetTokenSelf() (*fastly.Token, error) + ListCustomerTokens(i *fastly.ListCustomerTokensInput) ([]*fastly.Token, error) + ListTokens(i *fastly.ListTokensInput) ([]*fastly.Token, error) + + NewListKVStoreKeysPaginator(i *fastly.ListKVStoreKeysInput) fastly.PaginatorKVStoreEntries + + GetCustomTLSConfiguration(i *fastly.GetCustomTLSConfigurationInput) (*fastly.CustomTLSConfiguration, error) + ListCustomTLSConfigurations(i *fastly.ListCustomTLSConfigurationsInput) ([]*fastly.CustomTLSConfiguration, error) + UpdateCustomTLSConfiguration(i *fastly.UpdateCustomTLSConfigurationInput) (*fastly.CustomTLSConfiguration, error) + GetTLSActivation(i *fastly.GetTLSActivationInput) (*fastly.TLSActivation, error) + ListTLSActivations(i *fastly.ListTLSActivationsInput) ([]*fastly.TLSActivation, error) + UpdateTLSActivation(i *fastly.UpdateTLSActivationInput) (*fastly.TLSActivation, error) + CreateTLSActivation(i *fastly.CreateTLSActivationInput) (*fastly.TLSActivation, error) + DeleteTLSActivation(i *fastly.DeleteTLSActivationInput) error + + CreateCustomTLSCertificate(i *fastly.CreateCustomTLSCertificateInput) (*fastly.CustomTLSCertificate, error) + DeleteCustomTLSCertificate(i *fastly.DeleteCustomTLSCertificateInput) error + GetCustomTLSCertificate(i *fastly.GetCustomTLSCertificateInput) (*fastly.CustomTLSCertificate, error) + ListCustomTLSCertificates(i *fastly.ListCustomTLSCertificatesInput) ([]*fastly.CustomTLSCertificate, error) + UpdateCustomTLSCertificate(i *fastly.UpdateCustomTLSCertificateInput) (*fastly.CustomTLSCertificate, error) + + ListTLSDomains(i *fastly.ListTLSDomainsInput) ([]*fastly.TLSDomain, error) + + CreatePrivateKey(i *fastly.CreatePrivateKeyInput) (*fastly.PrivateKey, error) + DeletePrivateKey(i *fastly.DeletePrivateKeyInput) error + GetPrivateKey(i *fastly.GetPrivateKeyInput) (*fastly.PrivateKey, error) + ListPrivateKeys(i *fastly.ListPrivateKeysInput) ([]*fastly.PrivateKey, error) + + CreateBulkCertificate(i *fastly.CreateBulkCertificateInput) (*fastly.BulkCertificate, error) + DeleteBulkCertificate(i *fastly.DeleteBulkCertificateInput) error + GetBulkCertificate(i *fastly.GetBulkCertificateInput) (*fastly.BulkCertificate, error) + ListBulkCertificates(i *fastly.ListBulkCertificatesInput) ([]*fastly.BulkCertificate, error) + UpdateBulkCertificate(i *fastly.UpdateBulkCertificateInput) (*fastly.BulkCertificate, error) + + CreateTLSSubscription(i *fastly.CreateTLSSubscriptionInput) (*fastly.TLSSubscription, error) + DeleteTLSSubscription(i *fastly.DeleteTLSSubscriptionInput) error + GetTLSSubscription(i *fastly.GetTLSSubscriptionInput) (*fastly.TLSSubscription, error) + ListTLSSubscriptions(i *fastly.ListTLSSubscriptionsInput) ([]*fastly.TLSSubscription, error) + UpdateTLSSubscription(i *fastly.UpdateTLSSubscriptionInput) (*fastly.TLSSubscription, error) + + ListServiceAuthorizations(i *fastly.ListServiceAuthorizationsInput) (*fastly.ServiceAuthorizations, error) + GetServiceAuthorization(i *fastly.GetServiceAuthorizationInput) (*fastly.ServiceAuthorization, error) + CreateServiceAuthorization(i *fastly.CreateServiceAuthorizationInput) (*fastly.ServiceAuthorization, error) + UpdateServiceAuthorization(i *fastly.UpdateServiceAuthorizationInput) (*fastly.ServiceAuthorization, error) + DeleteServiceAuthorization(i *fastly.DeleteServiceAuthorizationInput) error + + CreateConfigStore(i *fastly.CreateConfigStoreInput) (*fastly.ConfigStore, error) + DeleteConfigStore(i *fastly.DeleteConfigStoreInput) error + GetConfigStore(i *fastly.GetConfigStoreInput) (*fastly.ConfigStore, error) + GetConfigStoreMetadata(i *fastly.GetConfigStoreMetadataInput) (*fastly.ConfigStoreMetadata, error) + ListConfigStores(i *fastly.ListConfigStoresInput) ([]*fastly.ConfigStore, error) + ListConfigStoreServices(i *fastly.ListConfigStoreServicesInput) ([]*fastly.Service, error) + UpdateConfigStore(i *fastly.UpdateConfigStoreInput) (*fastly.ConfigStore, error) + + CreateConfigStoreItem(i *fastly.CreateConfigStoreItemInput) (*fastly.ConfigStoreItem, error) + DeleteConfigStoreItem(i *fastly.DeleteConfigStoreItemInput) error + GetConfigStoreItem(i *fastly.GetConfigStoreItemInput) (*fastly.ConfigStoreItem, error) + ListConfigStoreItems(i *fastly.ListConfigStoreItemsInput) ([]*fastly.ConfigStoreItem, error) + UpdateConfigStoreItem(i *fastly.UpdateConfigStoreItemInput) (*fastly.ConfigStoreItem, error) + + CreateKVStore(i *fastly.CreateKVStoreInput) (*fastly.KVStore, error) + ListKVStores(i *fastly.ListKVStoresInput) (*fastly.ListKVStoresResponse, error) + DeleteKVStore(i *fastly.DeleteKVStoreInput) error + GetKVStore(i *fastly.GetKVStoreInput) (*fastly.KVStore, error) + ListKVStoreKeys(i *fastly.ListKVStoreKeysInput) (*fastly.ListKVStoreKeysResponse, error) + GetKVStoreKey(i *fastly.GetKVStoreKeyInput) (string, error) + DeleteKVStoreKey(i *fastly.DeleteKVStoreKeyInput) error + InsertKVStoreKey(i *fastly.InsertKVStoreKeyInput) error + BatchModifyKVStoreKey(i *fastly.BatchModifyKVStoreKeyInput) error + + CreateSecretStore(i *fastly.CreateSecretStoreInput) (*fastly.SecretStore, error) + GetSecretStore(i *fastly.GetSecretStoreInput) (*fastly.SecretStore, error) + DeleteSecretStore(i *fastly.DeleteSecretStoreInput) error + ListSecretStores(i *fastly.ListSecretStoresInput) (*fastly.SecretStores, error) + CreateSecret(i *fastly.CreateSecretInput) (*fastly.Secret, error) + GetSecret(i *fastly.GetSecretInput) (*fastly.Secret, error) + DeleteSecret(i *fastly.DeleteSecretInput) error + ListSecrets(i *fastly.ListSecretsInput) (*fastly.Secrets, error) + CreateClientKey() (*fastly.ClientKey, error) + GetSigningKey() (ed25519.PublicKey, error) + + CreateResource(i *fastly.CreateResourceInput) (*fastly.Resource, error) + DeleteResource(i *fastly.DeleteResourceInput) error + GetResource(i *fastly.GetResourceInput) (*fastly.Resource, error) + ListResources(i *fastly.ListResourcesInput) ([]*fastly.Resource, error) + UpdateResource(i *fastly.UpdateResourceInput) (*fastly.Resource, error) + + CreateERL(i *fastly.CreateERLInput) (*fastly.ERL, error) + DeleteERL(i *fastly.DeleteERLInput) error + GetERL(i *fastly.GetERLInput) (*fastly.ERL, error) + ListERLs(i *fastly.ListERLsInput) ([]*fastly.ERL, error) + UpdateERL(i *fastly.UpdateERLInput) (*fastly.ERL, error) + + CreateCondition(i *fastly.CreateConditionInput) (*fastly.Condition, error) + DeleteCondition(i *fastly.DeleteConditionInput) error + GetCondition(i *fastly.GetConditionInput) (*fastly.Condition, error) + ListConditions(i *fastly.ListConditionsInput) ([]*fastly.Condition, error) + UpdateCondition(i *fastly.UpdateConditionInput) (*fastly.Condition, error) + + GetProduct(i *fastly.ProductEnablementInput) (*fastly.ProductEnablement, error) + EnableProduct(i *fastly.ProductEnablementInput) (*fastly.ProductEnablement, error) + DisableProduct(i *fastly.ProductEnablementInput) error + + ListAlertDefinitions(i *fastly.ListAlertDefinitionsInput) (*fastly.AlertDefinitionsResponse, error) + CreateAlertDefinition(i *fastly.CreateAlertDefinitionInput) (*fastly.AlertDefinition, error) + GetAlertDefinition(i *fastly.GetAlertDefinitionInput) (*fastly.AlertDefinition, error) + UpdateAlertDefinition(i *fastly.UpdateAlertDefinitionInput) (*fastly.AlertDefinition, error) + DeleteAlertDefinition(i *fastly.DeleteAlertDefinitionInput) error + TestAlertDefinition(i *fastly.TestAlertDefinitionInput) error + ListAlertHistory(i *fastly.ListAlertHistoryInput) (*fastly.AlertHistoryResponse, error) + + ListObservabilityCustomDashboards(i *fastly.ListObservabilityCustomDashboardsInput) (*fastly.ListDashboardsResponse, error) + CreateObservabilityCustomDashboard(i *fastly.CreateObservabilityCustomDashboardInput) (*fastly.ObservabilityCustomDashboard, error) + GetObservabilityCustomDashboard(i *fastly.GetObservabilityCustomDashboardInput) (*fastly.ObservabilityCustomDashboard, error) + UpdateObservabilityCustomDashboard(i *fastly.UpdateObservabilityCustomDashboardInput) (*fastly.ObservabilityCustomDashboard, error) + DeleteObservabilityCustomDashboard(i *fastly.DeleteObservabilityCustomDashboardInput) error } // RealtimeStatsInterface is the subset of go-fastly's realtime stats API used here. type RealtimeStatsInterface interface { - GetRealtimeStatsJSON(*fastly.GetRealtimeStatsInput, interface{}) error + GetRealtimeStatsJSON(*fastly.GetRealtimeStatsInput, any) error } // Ensure that fastly.Client satisfies Interface. diff --git a/pkg/api/undocumented/undocumented.go b/pkg/api/undocumented/undocumented.go new file mode 100644 index 000000000..c6197a657 --- /dev/null +++ b/pkg/api/undocumented/undocumented.go @@ -0,0 +1,111 @@ +// Package undocumented provides abstractions for talking to undocumented Fastly +// API endpoints. +package undocumented + +import ( + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/fastly/cli/pkg/api" + "github.com/fastly/cli/pkg/debug" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/useragent" +) + +// EdgeComputeTrial is the API endpoint for activating a compute trial. +const EdgeComputeTrial = "/customer/%s/edge-compute-trial" + +// RequestTimeout is the timeout for the API network request. +const RequestTimeout = 5 * time.Second + +// APIError models a custom error for undocumented API calls. +type APIError struct { + Err error + StatusCode int +} + +// Error implements the error interface. +func (e APIError) Error() string { + return e.Err.Error() +} + +// NewError returns an APIError. +func NewError(err error, statusCode int) APIError { + return APIError{ + Err: err, + StatusCode: statusCode, + } +} + +// HTTPHeader represents a HTTP request header. +type HTTPHeader struct { + Key string + Value string +} + +// CallOptions is used as input to Call(). +type CallOptions struct { + APIEndpoint string + Body io.Reader + Debug bool + HTTPClient api.HTTPClient + HTTPHeaders []HTTPHeader + Method string + Path string + Token string +} + +// Call calls the given API endpoint and returns its response data. +// +// WARNING: Loads entire response body into memory. +func Call(opts CallOptions) (data []byte, err error) { + host := strings.TrimSuffix(opts.APIEndpoint, "/") + endpoint := fmt.Sprintf("%s%s", host, opts.Path) + + req, err := http.NewRequest(opts.Method, endpoint, opts.Body) + if err != nil { + return data, NewError(err, 0) + } + + if opts.Token != "" { + req.Header.Set("Fastly-Key", opts.Token) + } + req.Header.Set("User-Agent", useragent.Name) + for _, header := range opts.HTTPHeaders { + req.Header.Set(header.Key, header.Value) + } + + if opts.Debug { + debug.DumpHTTPRequest(req) + } + res, err := opts.HTTPClient.Do(req) + if opts.Debug { + debug.DumpHTTPResponse(res) + } + + if err != nil { + if urlErr, ok := err.(*url.Error); ok && urlErr.Timeout() { + return data, fsterr.RemediationError{ + Inner: err, + Remediation: fsterr.NetworkRemediation, + } + } + return data, NewError(err, 0) + } + defer res.Body.Close() // #nosec G307 + + data, err = io.ReadAll(res.Body) + if err != nil { + return []byte{}, NewError(err, res.StatusCode) + } + + if res.StatusCode >= http.StatusBadRequest { + return data, NewError(fmt.Errorf("error response: %q", data), res.StatusCode) + } + + return data, nil +} diff --git a/pkg/app/metadata.json b/pkg/app/metadata.json new file mode 100644 index 000000000..12cf578d7 --- /dev/null +++ b/pkg/app/metadata.json @@ -0,0 +1,1497 @@ +{ + "acl": { + "create": { + "examples": [ + { + "cmd": "fastly acl create --name robots --version active --autoclone", + "description": "Uses the `--version` flag to select the currently active service version and the `--autoclone` flag to enable automate cloning of the service version.", + "title": "Create a new ACL attached to the currently active service version" + } + ], + "apis": [ + "https://www.fastly.com/documentation/reference/api/acls/acl#create-acl" + ] + }, + "delete": { + "examples": [ + { + "cmd": "fastly acl delete --name robots --version 1", + "title": "Delete an ACL from the specified service version" + } + ], + "apis": [ + "https://www.fastly.com/documentation/reference/api/acls/acl#delete-acl" + ] + }, + "describe": { + "examples": [ + { + "cmd": "fastly acl describe --name robots --version active", + "title": "Retrieve a single ACL by name for the currently active service version" + } + ], + "apis": [ + "https://www.fastly.com/documentation/reference/api/acls/acl#get-acl" + ] + }, + "list": { + "examples": [ + { + "cmd": "fastly acl list --version 1", + "title": "List ACLs for the specified service version" + } + ], + "apis": [ + "https://www.fastly.com/documentation/reference/api/acls/acl#list-acls" + ] + }, + "update": { + "examples": [ + { + "cmd": "fastly acl update --name robots --new-name blocklist --version latest", + "title": "Update an ACL for the highest numbered existing service version" + } + ], + "apis": [ + "https://www.fastly.com/documentation/reference/api/acls/acl#update-acl" + ] + } + }, + "acl-entry": { + "create": { + "examples": [ + { + "cmd": "fastly acl-entry create --acl-id SU1Z0isxPaozGVKXdv0eY --ip 192.0.2.0", + "title": "Add an ACL entry to the specified ACL" + }, + { + "cmd": "fastly acl-entry create --acl-id SU1Z0isxPaozGVKXdv0eY --ip 192.0.2.0 --negated", + "title": "Add a negated ACL entry to the specified ACL" + } + ], + "apis": [ + "https://www.fastly.com/documentation/reference/api/acls/acl-entry#create-acl-entry" + ] + }, + "delete": { + "examples": [ + { + "cmd": "fastly acl-entry delete --acl-id SU1Z0isxPaozGVKXdv0eY --id 4DiuYrv9nVoa4HFmQmujT1", + "title": "Delete an ACL entry from the specified ACL" + } + ], + "apis": [ + "https://www.fastly.com/documentation/reference/api/acls/acl-entry#delete-acl-entry" + ] + }, + "describe": { + "examples": [ + { + "cmd": "fastly acl-entry describe --acl-id SU1Z0isxPaozGVKXdv0eY --id x9KzsrACXZv8tPwlEDsKb6", + "title": "Retrieve a single ACL entry from the specified ACL" + } + ], + "apis": [ + "https://www.fastly.com/documentation/reference/api/acls/acl-entry#get-acl-entry" + ] + }, + "list": { + "examples": [ + { + "cmd": "fastly acl-entry list --acl-id SU1Z0isxPaozGVKXdv0eY", + "title": "List ACL entries from the specified ACL" + } + ], + "apis": [ + "https://www.fastly.com/documentation/reference/api/acls/acl-entry#list-acl-entries" + ] + }, + "update": { + "examples": [ + { + "cmd": "fastly acl-entry update --acl-id SU1Z0isxPaozGVKXdv0eY --id x9KzsrACXZv8tPwlEDsKb6 --negated", + "title": "Update an ACL entry in the specified ACL" + }, + { + "cmd": "fastly acl-entry update --acl-id SU1Z0isxPaozGVKXdv0eY --file ./batch.json", + "description": "Update multiple ACL entries using a [JSON batch file](https://www.fastly.com/documentation/reference/api/acls/acl-entry#bulk-update-acl-entries).", + "title": "Update multiple ACL entries in the specified ACL using a local file" + }, + { + "cmd": "fastly acl-entry update --acl-id SU1Z0isxPaozGVKXdv0eY --file \"$(< batch.json)\"", + "description": "Update multiple ACL entries using a [JSON batch file](https://www.fastly.com/documentation/reference/api/acls/acl-entry#bulk-update-acl-entries)'s content passed in using shell command substitution.", + "title": "Update multiple ACL entries in the specified ACL using command substitution" + } + ], + "apis": [ + "https://www.fastly.com/documentation/reference/api/acls/acl-entry#bulk-update-acl-entries", + "https://www.fastly.com/documentation/reference/api/acls/acl-entry#update-acl-entry" + ] + } + }, + "auth-token": { + "create": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/auth-tokens#create-token" + ] + }, + "delete": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/auth-tokens#revoke-token-current", + "https://www.fastly.com/documentation/reference/api/auth-tokens#bulk-revoke-tokens", + "https://www.fastly.com/documentation/reference/api/auth-tokens#revoke-token" + ] + }, + "describe": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/auth-tokens#get-token-current" + ] + }, + "list": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/auth-tokens#list-tokens-customer", + "https://www.fastly.com/documentation/reference/api/auth-tokens#list-tokens-user" + ] + } + }, + "backend": { + "create": { + "examples": [ + { + "cmd": "fastly backend create --name example --address example.com --version active --autoclone", + "description": "Create a backend with a hostname assigned to the `--address` flag. The `--override-host`, `--ssl-cert-hostname` and `--ssl-sni-hostname` will default to the same hostname assigned to `--address`.", + "title": "Create a backend on the currently active service version" + } + ], + "apis": [ + "https://www.fastly.com/documentation/reference/api/services/backend#create-backend" + ] + }, + "delete": { + "examples": [ + { + "cmd": "fastly backend delete --name example --version latest", + "title": "Delete a backend from the highest numbered existing service version" + } + ], + "apis": [ + "https://www.fastly.com/documentation/reference/api/services/backend#delete-backend" + ] + }, + "describe": { + "examples": [ + { + "cmd": "fastly backend describe --name example --version 1", + "title": "Show detailed information about a backend on the specified service version" + } + ], + "apis": [ + "https://www.fastly.com/documentation/reference/api/services/backend#get-backend" + ] + }, + "list": { + "examples": [ + { + "cmd": "fastly backend list --version active", + "title": "List backends on the currently active service version" + } + ], + "apis": [ + "https://www.fastly.com/documentation/reference/api/services/backend#list-backends" + ] + }, + "update": { + "examples": [ + { + "cmd": "fastly backend update --name example --new-name testing --version latest", + "title": "Update a backend on the highest numbered existing service version" + } + ], + "apis": [ + "https://www.fastly.com/documentation/reference/api/services/backend#update-backend" + ] + } + }, + "compute": { + "build": { + "examples": [ + { + "cmd": "fastly compute build", + "description": "In the `fastly.toml` manifest define a new `[scripts]` table and within it define a `build` key with your Yarn instructions. For example, `build = \"yarn install && yarn build\"`.", + "title": "Build a JavaScript Compute package using Yarn instead of NPM" + } + ] + }, + "deploy": { + "examples": [ + { + "cmd": "fastly compute deploy --accept-defaults", + "description": "The optional `--accept-defaults` flag accepts default values for all prompts if configured via the [fastly.toml](https://www.fastly.com/documentation/reference/compute/fastly-toml) `[setup]` section and performs a deploy non-interactively", + "title": "Deploy a package to a Fastly Compute service" + }, + { + "cmd": "fastly compute deploy --package ./pkg/example.tar.gz", + "description": "Use the fastly compute pack command to package up a pre-compiled Wasm binary and then reference the generated archive file when deploying.", + "title": "Deploy a custom package to a Fastly Compute service" + } + ], + "apis": [ + "https://www.fastly.com/documentation/reference/api/services/service#create-service", + "https://www.fastly.com/documentation/reference/api/services/service#get-service", + "https://www.fastly.com/documentation/reference/api/services/package#get-package", + "https://www.fastly.com/documentation/reference/api/services/package#put-package" + ] + }, + "init": { + "examples": [ + { + "cmd": "fastly compute init --name example --language rust", + "description": "To initialize a new Compute package you must select a supported language. The language can be provided using the optional `--language` flag, which supports tab completion hints, or the flag can be omitted and you'll be prompted interactively. The `--name` flag can also be omitted, which will result in the CLI prompting you interactively.", + "title": "Initialize a new Compute package locally" + }, + { + "cmd": "fastly compute init --from=https://fiddle.fastly.dev/fiddle/0220c0d2", + "description": "Any [Compute examples](https://www.fastly.com/documentation/solutions/examples) can be used as a source template for your new package.", + "title": "Initialize a new Compute package locally using a remote package template" + }, + { + "cmd": "fastly compute init --directory ./example", + "description": "We recommend that you change to the new project directory after running this command, before executing further CLI commands.", + "title": "Initialize a new Compute package locally in a different directory" + } + ] + }, + "pack": { + "examples": [ + { + "cmd": "fastly compute pack --wasm-binary ./bin/main.wasm", + "description": "Write Compute applications in [any WASI-supporting language](https://www.fastly.com/documentation/guides/compute/custom) and use fastly compute pack to package the pre-compiled Wasm binary into a supported format.", + "title": "Package a pre-compiled Wasm binary for a Fastly Compute service" + } + ] + }, + "publish": { + "examples": [ + { + "cmd": "fastly compute publish --accept-defaults", + "description": "The fastly compute publish command is a convenience wrapper around the existing build and deploy commands. All flags present on the fastly compute build and fastly compute deploy commands are available to use here.", + "title": "Build and deploy a Compute package to a Fastly service" + } + ], + "apis": [ + "https://www.fastly.com/documentation/reference/api/services/service#create-service", + "https://www.fastly.com/documentation/reference/api/services/service#get-service", + "https://www.fastly.com/documentation/reference/api/services/package#get-package", + "https://www.fastly.com/documentation/reference/api/services/package#put-package" + ] + }, + "serve": { + "examples": [ + { + "cmd": "fastly compute serve --watch", + "description": "The `compute serve` command wraps the existing build command. All flags present on the fastly compute build command are available to use here. Additionally, the `--watch` command enables 'hot reloading' of your project code whenever changes are made to the source code.", + "title": "Build and run a Compute package locally" + } + ] + }, + "update": { + "examples": [ + { + "cmd": "fastly compute update --package ./pkg/example.tar.gz --version active --autoclone", + "description": "Uses the `--version` flag to select the currently active service version and the `--autoclone` flag to enable automate cloning of the service version.", + "title": "Update a package on the currently active service version" + } + ], + "apis": [ + "https://www.fastly.com/documentation/reference/api/services/package#put-package" + ] + }, + "validate": { + "examples": [ + { + "cmd": "fastly compute validate --package ./pkg/example.tar.gz", + "title": "Validate a Compute package" + } + ] + } + }, + "config-store-entry": { + "delete": { + "examples": [ + { + "cmd": "fastly config-store-entry delete --all --store-id --batch-size 30 --auto-yes", + "description": "Delete all entries from the Config Store in batches of 30 and ignoring the warning prompt.", + "title": "Delete all entries from the Config Store" + } + ], + "apis": [ + "https://www.fastly.com/documentation/reference/api/services/resources/config-store-item#list-config-store-items", + "https://www.fastly.com/documentation/reference/api/services/resources/config-store-item#delete-config-store-item" + ] + } + }, + "dictionary": { + "create": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/dictionaries/dictionary#get-dictionary" + ] + }, + "delete": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/dictionaries/dictionary#delete-dictionary" + ] + }, + "describe": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/dictionaries/dictionary#get-dictionary", + "https://www.fastly.com/documentation/reference/api/dictionaries/dictionary-info#get-dictionary-info", + "https://www.fastly.com/documentation/reference/api/dictionaries/dictionary-item#list-dictionary-items" + ] + }, + "list": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/dictionaries/dictionary#list-dictionaries" + ] + }, + "update": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/dictionaries/dictionary#update-dictionary" + ] + } + }, + "dictionary-item": { + "create": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/dictionaries/dictionary-item#create-dictionary-item" + ] + }, + "delete": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/dictionaries/dictionary-item#delete-dictionary-item" + ] + }, + "describe": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/dictionaries/dictionary-item#get-dictionary-item" + ] + }, + "list": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/dictionaries/dictionary-item#list-dictionary-items" + ] + }, + "update": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/dictionaries/dictionary-item#bulk-update-dictionary-item", + "https://www.fastly.com/documentation/reference/api/dictionaries/dictionary-item#upsert-dictionary-item" + ] + } + }, + "domain": { + "create": { + "examples": [ + { + "cmd": "fastly domain create --name example.com --version latest --autoclone", + "description": "Uses the `--version` flag to dynamically determine the highest numbered existing service version, and the `--autoclone` flag if the latest version is currently 'active'", + "title": "Create a domain on the highest numbered existing service version" + } + ], + "apis": [ + "https://www.fastly.com/documentation/reference/api/services/domain#create-domain" + ] + }, + "delete": { + "examples": [ + { + "cmd": "fastly domain delete --name example.com --version latest --autoclone", + "description": "Uses the `--version` flag to dynamically determine the highest numbered existing service version, and the `--autoclone` flag if the latest version is currently 'active'", + "title": "Delete a domain from the highest numbered existing service version" + } + ], + "apis": [ + "https://www.fastly.com/documentation/reference/api/services/domain#delete-domain" + ] + }, + "describe": { + "examples": [ + { + "cmd": "fastly domain describe --name example.com --version 1", + "title": "Show detailed information about a domain on the specified service version" + } + ], + "apis": [ + "https://www.fastly.com/documentation/reference/api/services/domain#get-domain" + ] + }, + "list": { + "examples": [ + { + "cmd": "fastly domain list --version active", + "title": "List domains on the currently active service version" + } + ], + "apis": [ + "https://www.fastly.com/documentation/reference/api/services/domain#list-domains" + ] + }, + "update": { + "examples": [ + { + "cmd": "fastly domain update --name example.com --new-name example.net --version active --autoclone", + "description": "Uses the `--version` flag to select the currently active service version and the `--autoclone` flag to enable automate cloning of the service version.", + "title": "Update a domain on the currently active service version" + } + ], + "apis": [ + "https://www.fastly.com/documentation/reference/api/services/domain#update-domain" + ] + }, + "validate": { + "examples": [ + { + "cmd": "fastly domain validate --name example.com --version 2", + "description": "To validate all domains at once replace the `--name` flag with `--all`.", + "title": "Check the status of a specific domain's DNS record for the specified service version" + } + ], + "apis": [ + "https://www.fastly.com/documentation/reference/api/services/domain#check-domains", + "https://www.fastly.com/documentation/reference/api/services/domain#check-domain" + ] + } + }, + "healthcheck": { + "create": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/services/healthcheck#create-healthcheck" + ] + }, + "delete": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/services/healthcheck#delete-healthcheck" + ] + }, + "describe": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/services/healthcheck#get-healthcheck" + ] + }, + "list": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/services/healthcheck#list-healthchecks" + ] + }, + "update": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/services/healthcheck#update-healthcheck" + ] + } + }, + "ip-list": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/utils/public-ip-list" + ] + }, + "kv-store-entry": { + "create": { + "examples": [ + { + "cmd": "echo '{\"key\":\"example\",\"value\":\"VkFMVUU=\"}' | fastly kv-store-entry create --stdin", + "description": "Each JSON entry should be separated by a newline (\n) delimiter, and the value to be inserted should be base64 encoded.", + "title": "Stream data into a KV Store using STDIN" + }, + { + "cmd": "fastly kv-store-entry create --file data.json", + "description": "Each entry in the JSON file should be its own JSON object separated by a newline, and the value to be inserted should be base64 encoded.", + "title": "Stream data into a KV Store using a JSON file" + }, + { + "cmd": "fastly kv-store-entry create --dir ./data/", + "description": "The filename will be used as the key, and the file contents will be used as the value (unlike other options, the file content doesn't need to be base64 encoded).", + "title": "Concurrently insert data into a KV Store using a file directory structure" + } + ], + "apis": [ + "https://www.fastly.com/documentation/reference/api/services/resources/kv-store-item#set-value-for-key" + ] + } + }, + "logging": { + "azureblob": { + "create": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/azureblob#create-log-azure" + ] + }, + "delete": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/azureblob#delete-log-azure" + ] + }, + "describe": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/azureblob#get-log-azure" + ] + }, + "list": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/azureblob#list-log-azure" + ] + }, + "update": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/azureblob#update-log-azure" + ] + } + }, + "bigquery": { + "create": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/bigquery#create-log-bigquery" + ] + }, + "delete": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/bigquery#delete-log-bigquery" + ] + }, + "describe": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/bigquery#get-log-bigquery" + ] + }, + "list": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/bigquery#list-log-bigquery" + ] + }, + "update": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/bigquery#update-log-bigquery" + ] + } + }, + "cloudfiles": { + "create": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/cloudfiles#create-log-cloudfiles" + ] + }, + "delete": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/cloudfiles#delete-log-cloudfiles" + ] + }, + "describe": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/cloudfiles#get-log-cloudfiles" + ] + }, + "list": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/cloudfiles#list-log-cloudfiles" + ] + }, + "update": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/cloudfiles#update-log-cloudfiles" + ] + } + }, + "datadog": { + "create": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/datadog#create-log-datadog" + ] + }, + "delete": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/datadog#delete-log-datadog" + ] + }, + "describe": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/datadog#get-log-datadog" + ] + }, + "list": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/datadog#list-log-datadog" + ] + }, + "update": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/datadog#update-log-datadog" + ] + } + }, + "digitalocean": { + "create": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/digitalocean#create-log-digocean" + ] + }, + "delete": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/digitalocean#delete-log-digocean" + ] + }, + "describe": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/digitalocean#get-log-digocean" + ] + }, + "list": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/digitalocean#list-log-digocean" + ] + }, + "update": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/digitalocean#update-log-digocean" + ] + } + }, + "elasticsearch": { + "create": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/elasticsearch#create-log-elasticsearch" + ] + }, + "delete": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/elasticsearch#delete-log-elasticsearch" + ] + }, + "describe": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/elasticsearch#get-log-elasticsearch" + ] + }, + "list": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/elasticsearch#list-log-elasticsearch" + ] + }, + "update": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/elasticsearch#update-log-elasticsearch" + ] + } + }, + "ftp": { + "create": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/ftp#create-log-ftp" + ] + }, + "delete": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/ftp#delete-log-ftp" + ] + }, + "describe": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/ftp#get-log-ftp" + ] + }, + "list": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/ftp#list-log-ftp" + ] + }, + "update": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/ftp#update-log-ftp" + ] + } + }, + "gcs": { + "create": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/gcs#create-log-gcs" + ] + }, + "delete": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/gcs#delete-log-gcs" + ] + }, + "describe": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/gcs#get-log-gcs" + ] + }, + "list": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/gcs#list-log-gcs" + ] + }, + "update": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/gcs#update-log-gcs" + ] + } + }, + "googlepubsub": { + "create": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/google-pubsub#create-log-gcp-pubsub" + ] + }, + "delete": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/google-pubsub#delete-log-gcp-pubsub" + ] + }, + "describe": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/google-pubsub#get-log-gcp-pubsub" + ] + }, + "list": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/google-pubsub#list-log-gcp-pubsub" + ] + }, + "update": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/google-pubsub#update-log-gcp-pubsub" + ] + } + }, + "heroku": { + "create": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/heroku#create-log-heroku" + ] + }, + "delete": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/heroku#delete-log-heroku" + ] + }, + "describe": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/heroku#get-log-heroku" + ] + }, + "list": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/heroku#list-log-heroku" + ] + }, + "update": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/heroku#update-log-heroku" + ] + } + }, + "honeycomb": { + "create": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/honeycomb#create-log-honeycomb" + ] + }, + "delete": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/honeycomb#delete-log-honeycomb" + ] + }, + "describe": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/honeycomb#get-log-honeycomb" + ] + }, + "list": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/honeycomb#list-log-honeycomb" + ] + }, + "update": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/honeycomb#update-log-honeycomb" + ] + } + }, + "https": { + "create": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/https#create-log-https" + ] + }, + "delete": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/https#delete-log-https" + ] + }, + "describe": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/https#get-log-https" + ] + }, + "list": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/https#list-log-https" + ] + }, + "update": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/https#update-log-https" + ] + } + }, + "kafka": { + "create": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/kafka#create-log-kafka" + ] + }, + "delete": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/kafka#delete-log-kafka" + ] + }, + "describe": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/kafka#get-log-kafka" + ] + }, + "list": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/kafka#list-log-kafka" + ] + }, + "update": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/kafka#update-log-kafka" + ] + } + }, + "kinesis": { + "create": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/kinesis#create-log-kinesis" + ] + }, + "delete": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/kinesis#delete-log-kinesis" + ] + }, + "describe": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/kinesis#get-log-kinesis" + ] + }, + "list": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/kinesis#list-log-kinesis" + ] + }, + "update": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/kinesis#update-log-kinesis" + ] + } + }, + "logentries": { + "create": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/logentries#create-log-logentries" + ] + }, + "delete": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/logentries#delete-log-logentries" + ] + }, + "describe": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/logentries#get-log-logentries" + ] + }, + "list": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/logentries#list-log-logentries" + ] + }, + "update": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/logentries#update-log-logentries" + ] + } + }, + "loggly": { + "create": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/loggly#create-log-loggly" + ] + }, + "delete": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/loggly#delete-log-loggly" + ] + }, + "describe": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/loggly#get-log-loggly" + ] + }, + "list": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/loggly#list-log-loggly" + ] + }, + "update": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/loggly#update-log-loggly" + ] + } + }, + "logshuttle": { + "create": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/logshuttle#create-log-logshuttle" + ] + }, + "delete": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/logshuttle#delete-log-logshuttle" + ] + }, + "describe": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/logshuttle#get-log-logshuttle" + ] + }, + "list": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/logshuttle#list-log-logshuttle" + ] + }, + "update": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/logshuttle#update-log-logshuttle" + ] + } + }, + "newrelic": { + "create": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/new-relic#create-log-newrelic" + ] + }, + "delete": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/new-relic#delete-log-newrelic" + ] + }, + "describe": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/new-relic#get-log-newrelic" + ] + }, + "list": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/new-relic#list-log-newrelic" + ] + }, + "update": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/new-relic#update-log-newrelic" + ] + } + }, + "openstack": { + "create": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/openstack#get-log-openstack" + ] + }, + "delete": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/openstack#delete-log-openstack" + ] + }, + "describe": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/openstack#get-log-openstack" + ] + }, + "list": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/openstack#list-log-openstack" + ] + }, + "update": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/openstack#update-log-openstack" + ] + } + }, + "papertrail": { + "create": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/papertrail#create-log-papertrail" + ] + }, + "delete": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/papertrail#delete-log-papertrail" + ] + }, + "describe": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/papertrail#get-log-papertrail" + ] + }, + "list": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/papertrail#list-log-papertrail" + ] + }, + "update": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/papertrail#update-log-papertrail" + ] + } + }, + "s3": { + "create": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/s3#create-log-aws-s3" + ] + }, + "delete": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/s3#delete-log-aws-s3" + ] + }, + "describe": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/s3#get-log-aws-s3" + ] + }, + "list": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/s3#list-log-aws-s3" + ] + }, + "update": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/s3#update-log-aws-s3" + ] + } + }, + "scalyr": { + "create": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/scalyr#create-log-scalyr" + ] + }, + "delete": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/scalyr#delete-log-scalyr" + ] + }, + "describe": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/scalyr#get-log-scalyr" + ] + }, + "list": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/scalyr#list-log-scalyr" + ] + }, + "update": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/scalyr#update-log-scalyr" + ] + } + }, + "sftp": { + "create": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/sftp#create-log-sftp" + ] + }, + "delete": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/sftp#delete-log-sftp" + ] + }, + "describe": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/sftp#get-log-sftp" + ] + }, + "list": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/sftp#list-log-sftp" + ] + }, + "update": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/sftp#update-log-sftp" + ] + } + }, + "splunk": { + "create": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/splunk#create-log-splunk" + ] + }, + "delete": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/splunk#delete-log-splunk" + ] + }, + "describe": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/splunk#get-log-splunk" + ] + }, + "list": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/splunk#list-log-splunk" + ] + }, + "update": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/splunk#update-log-splunk" + ] + } + }, + "sumologic": { + "create": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/sumologic#create-log-sumologic" + ] + }, + "delete": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/sumologic#delete-log-sumologic" + ] + }, + "describe": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/sumologic#list-log-sumologic" + ] + }, + "list": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/sumologic#list-log-sumologic" + ] + }, + "update": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/sumologic#update-log-sumologic" + ] + } + }, + "syslog": { + "create": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/syslog#create-log-syslog" + ] + }, + "delete": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/syslog#delete-log-syslog" + ] + }, + "describe": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/syslog#get-log-syslog" + ] + }, + "list": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/syslog#list-log-syslog" + ] + }, + "update": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/logging/syslog#update-log-syslog" + ] + } + } + }, + "metadata": { + "examples": [ + { + "cmd": "fastly compute metadata --enable", + "title": "Enable all metadata collection information" + }, + { + "cmd": "fastly compute metadata --disable", + "title": "Disable all metadata collection information" + }, + { + "cmd": "fastly compute metadata --enable-build --enable-machine --enable-package", + "title": "Enable specific metadata collection information" + }, + { + "cmd": "fastly compute metadata --disable-build --disable-machine --disable-package", + "title": "Disable specific metadata collection information" + } + ] + }, + "pops": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/utils/pops#list-pops" + ] + }, + "profile": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/auth-tokens#get-token-current", + "https://www.fastly.com/documentation/reference/api/account/user#get-user" + ] + }, + "purge": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/purging#purge-all", + "https://www.fastly.com/documentation/reference/api/purging#bulk-purge-tag", + "https://www.fastly.com/documentation/reference/api/purging#purge-tag", + "https://www.fastly.com/documentation/reference/api/purging#purge-single-url" + ] + }, + "search": { + "create": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/services/service#create-service" + ] + }, + "delete": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/services/service#get-service-detail", + "https://www.fastly.com/documentation/reference/api/services/version#deactivate-service-version", + "https://www.fastly.com/documentation/reference/api/services/service#delete-service" + ] + }, + "describe": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/services/service#get-service-detail" + ] + }, + "list": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/services/service#list-services" + ] + }, + "search": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/services/service#search-service" + ] + }, + "update": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/services/service#update-service" + ] + } + }, + "service-version": { + "activate": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/services/version#activate-service-version" + ] + }, + "clone": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/services/version#clone-service-version" + ] + }, + "deactivate": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/services/version#deactivate-service-version" + ] + }, + "list": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/services/version#list-service-versions" + ] + }, + "lock": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/services/version#lock-service-version" + ] + }, + "update": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/services/version#update-service-version" + ] + } + }, + "stats": { + "historical": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/metrics-stats/historical-stats#get-hist-stats-service" + ] + }, + "realtime": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/metrics-stats/realtime#get-stats-last-second" + ] + }, + "regional": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/metrics-stats/historical-stats#get-regions" + ] + } + }, + "user": { + "create": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/account/user#create-user" + ] + }, + "delete": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/account/user#delete-user" + ] + }, + "describe": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/account/user#get-current-user", + "https://www.fastly.com/documentation/reference/api/account/user#get-user" + ] + }, + "list": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/account/customer#list-users" + ] + }, + "update": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/account/user#request-password-reset", + "https://www.fastly.com/documentation/reference/api/account/user#update-user" + ] + } + }, + "vcl": { + "condition": { + "create": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/vcl-services/condition#create-condition" + ] + }, + "delete": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/vcl-services/condition#delete-condition" + ] + }, + "describe": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/vcl-services/condition#get-condition" + ] + }, + "list": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/vcl-services/condition#list-conditions" + ] + }, + "update": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/vcl-services/condition#update-condition" + ] + } + }, + "custom": { + "create": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/vcl-services/vcl#create-custom-vcl" + ] + }, + "delete": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/vcl-services/vcl#delete-custom-vcl" + ] + }, + "describe": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/vcl-services/vcl#get-custom-vcl" + ] + }, + "list": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/vcl-services/vcl#list-custom-vcl" + ] + }, + "update": { + "apis": [ + "https://www.fastly.com/documentation/reference/api/vcl-services/vcl#update-custom-vcl" + ] + } + }, + "snippet": { + "create": { + "examples": [ + { + "cmd": "fastly vcl snippet create --name example --content ./example.vcl --type recv --version latest", + "description": "The `--type` flag additionally supports tab completion hints for valid location values.", + "title": "Create a snippet on the highest numbered existing service version, using a local file" + }, + { + "cmd": "fastly vcl snippet create --name example --content \"$(< example.vcl)\" --type recv --version latest", + "description": "The `--type` flag additionally supports tab completion hints for valid location values.", + "title": "Create a snippet on the highest numbered existing service version, using command substitution" + } + ], + "apis": [ + "https://www.fastly.com/documentation/reference/api/vcl-services/snippet#create-snippet" + ] + }, + "delete": { + "examples": [ + { + "cmd": "fastly vcl snippet delete --name example --version 1", + "title": "Delete a specific snippet from the specified service version" + } + ], + "apis": [ + "https://www.fastly.com/documentation/reference/api/vcl-services/snippet#delete-snippet" + ] + }, + "describe": { + "examples": [ + { + "cmd": "fastly vcl snippet describe --snippet-id 3p8fPcMVB6OqbMxGT83hb9 --dynamic --version active", + "description": "To describe a 'versioned' snippet replace the `--snippet-id` and `--dynamic` flags with `--name`.", + "title": "Get the uploaded VCL snippet for the currently active service version" + } + ], + "apis": [ + "https://www.fastly.com/documentation/reference/api/vcl-services/snippet#get-snippet-dynamic", + "https://www.fastly.com/documentation/reference/api/vcl-services/snippet#get-snippet" + ] + }, + "list": { + "examples": [ + { + "cmd": "fastly vcl snippet list --version active", + "title": "List the uploaded VCL snippets for the currently active service version" + } + ], + "apis": [ + "https://www.fastly.com/documentation/reference/api/vcl-services/snippet#list-snippets" + ] + }, + "update": { + "examples": [ + { + "cmd": "fastly vcl snippet update --snippet-id 2k5KYQCSJERvR8aB3cbOdA --dynamic --type deliver --version latest", + "description": "To update a 'versioned' snippet replace the `--snippet-id` and `--dynamic` flags with `--name`.", + "title": "Update a VCL snippet for the highest numbered existing service version" + } + ], + "apis": [ + "https://www.fastly.com/documentation/reference/api/vcl-services/snippet#update-snippet-dynamic", + "https://www.fastly.com/documentation/reference/api/vcl-services/snippet#update-snippet" + ] + } + } + } +} diff --git a/pkg/app/run.go b/pkg/app/run.go index 6c05c5848..195be4f8d 100644 --- a/pkg/app/run.go +++ b/pkg/app/run.go @@ -1,94 +1,300 @@ package app import ( - "bytes" - "context" + "encoding/json" + "errors" "fmt" "io" + "net/http" "os" - "regexp" + "slices" + "strconv" + "strings" "time" + "github.com/fatih/color" + "github.com/hashicorp/cap/oidc" + "github.com/skratchdot/open-golang/open" + + "github.com/fastly/go-fastly/v10/fastly" + "github.com/fastly/kingpin" + "github.com/fastly/cli/pkg/api" - "github.com/fastly/cli/pkg/backend" - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute" + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/auth" + "github.com/fastly/cli/pkg/commands" + "github.com/fastly/cli/pkg/commands/compute" + "github.com/fastly/cli/pkg/commands/sso" + "github.com/fastly/cli/pkg/commands/update" + "github.com/fastly/cli/pkg/commands/version" "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/configure" - "github.com/fastly/cli/pkg/domain" - "github.com/fastly/cli/pkg/edgedictionary" - "github.com/fastly/cli/pkg/edgedictionaryitem" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/healthcheck" - "github.com/fastly/cli/pkg/logging" - "github.com/fastly/cli/pkg/logging/azureblob" - "github.com/fastly/cli/pkg/logging/bigquery" - "github.com/fastly/cli/pkg/logging/cloudfiles" - "github.com/fastly/cli/pkg/logging/datadog" - "github.com/fastly/cli/pkg/logging/digitalocean" - "github.com/fastly/cli/pkg/logging/elasticsearch" - "github.com/fastly/cli/pkg/logging/ftp" - "github.com/fastly/cli/pkg/logging/gcs" - "github.com/fastly/cli/pkg/logging/googlepubsub" - "github.com/fastly/cli/pkg/logging/heroku" - "github.com/fastly/cli/pkg/logging/honeycomb" - "github.com/fastly/cli/pkg/logging/https" - "github.com/fastly/cli/pkg/logging/kafka" - "github.com/fastly/cli/pkg/logging/kinesis" - "github.com/fastly/cli/pkg/logging/logentries" - "github.com/fastly/cli/pkg/logging/loggly" - "github.com/fastly/cli/pkg/logging/logshuttle" - "github.com/fastly/cli/pkg/logging/openstack" - "github.com/fastly/cli/pkg/logging/papertrail" - "github.com/fastly/cli/pkg/logging/s3" - "github.com/fastly/cli/pkg/logging/scalyr" - "github.com/fastly/cli/pkg/logging/sftp" - "github.com/fastly/cli/pkg/logging/splunk" - "github.com/fastly/cli/pkg/logging/sumologic" - "github.com/fastly/cli/pkg/logging/syslog" - "github.com/fastly/cli/pkg/logs" + "github.com/fastly/cli/pkg/env" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/github" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/lookup" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/profile" "github.com/fastly/cli/pkg/revision" - "github.com/fastly/cli/pkg/service" - "github.com/fastly/cli/pkg/serviceversion" - "github.com/fastly/cli/pkg/stats" + "github.com/fastly/cli/pkg/sync" "github.com/fastly/cli/pkg/text" - "github.com/fastly/cli/pkg/update" - "github.com/fastly/cli/pkg/version" - "github.com/fastly/cli/pkg/whoami" - "github.com/fastly/go-fastly/v3/fastly" - "github.com/fastly/kingpin" ) -var ( - completionRegExp = regexp.MustCompile("completion-(?:script-)?(?:bash|zsh)$") -) +// Run kick starts the CLI application. +func Run(args []string, stdin io.Reader) error { + data, err := Init(args, stdin) + if err != nil { + return fmt.Errorf("failed to initialise application: %w", err) + } + return Exec(data) +} + +// Init constructs all the required objects and data for Exec(). +// +// NOTE: We define as a package level variable so we can mock output for tests. +var Init = func(args []string, stdin io.Reader) (*global.Data, error) { + // Parse the arguments provided by the user via the command-line interface. + args = args[1:] + + // Define a HTTP client that will be used for making arbitrary HTTP requests. + httpClient := &http.Client{Timeout: time.Minute * 2} + + // Define the standard input/output streams. + var ( + in = stdin + out io.Writer = sync.NewWriter(color.Output) + ) + + // Read relevant configuration options from the user's environment. + var e config.Environment + e.Read(env.Parse(os.Environ())) + + // Identify verbose flag early (before Kingpin parser has executed) so we can + // print additional output related to the CLI configuration. + var verboseOutput bool + for _, seg := range args { + if seg == "-v" || seg == "--verbose" { + verboseOutput = true + } + } + + // Identify auto-yes/non-interactive flag early (before Kingpin parser has + // executed) so we can handle the interactive prompts appropriately with + // regards to processing the CLI configuration. + var autoYes, nonInteractive bool + for _, seg := range args { + if seg == "-y" || seg == "--auto-yes" { + autoYes = true + } + if seg == "-i" || seg == "--non-interactive" { + nonInteractive = true + } + } + + // Extract a subset of configuration options from the local app directory. + var cfg config.File + cfg.SetAutoYes(autoYes) + cfg.SetNonInteractive(nonInteractive) + if err := cfg.Read(config.FilePath, in, out, fsterr.Log, verboseOutput); err != nil { + return nil, err + } + + // Extract user's project configuration from the fastly.toml manifest. + var md manifest.Data + md.File.Args = args + md.File.SetErrLog(fsterr.Log) + md.File.SetOutput(out) -// Run constructs the application including all of the subcommands, parses the + // NOTE: We skip handling the error because not all commands relate to Compute. + _ = md.File.Read(manifest.Filename) + + factory := func(token, endpoint string, debugMode bool) (api.Interface, error) { + client, err := fastly.NewClientForEndpoint(token, endpoint) + if debugMode { + client.DebugMode = true + } + return client, err + } + + // Identify debug-mode flag early (before Kingpin parser has executed) so we + // can inform the github versioners that we're in debug mode. + var debugMode bool + for _, seg := range args { + if seg == "--debug-mode" { + debugMode = true + } + } + + versioners := global.Versioners{ + CLI: github.New(github.Opts{ + DebugMode: debugMode, + HTTPClient: httpClient, + Org: "fastly", + Repo: "cli", + Binary: "fastly", + }), + Viceroy: github.New(github.Opts{ + DebugMode: debugMode, + HTTPClient: httpClient, + Org: "fastly", + Repo: "viceroy", + Binary: "viceroy", + Version: md.File.LocalServer.ViceroyVersion, + }), + WasmTools: github.New(github.Opts{ + DebugMode: debugMode, + HTTPClient: httpClient, + Org: "bytecodealliance", + Repo: "wasm-tools", + Binary: "wasm-tools", + External: true, + Nested: true, + }), + } + + return &global.Data{ + APIClientFactory: factory, + Args: args, + Config: cfg, + ConfigPath: config.FilePath, + Env: e, + ErrLog: fsterr.Log, + ExecuteWasmTools: compute.ExecuteWasmTools, + HTTPClient: httpClient, + Manifest: &md, + Opener: open.Run, + Output: out, + Versioners: versioners, + Input: in, + }, nil +} + +// Exec constructs the application including all of the subcommands, parses the // args, invokes the client factory with the token to create a Fastly API // client, and executes the chosen command, using the provided io.Reader and // io.Writer for input and output, respectively. In the real CLI, func main is // just a simple shim to this function; it exists to make end-to-end testing of // commands easier/possible. // -// The Run helper should NOT output any error-related information to the out +// The Exec helper should NOT output any error-related information to the out // io.Writer. All error-related information should be encoded into an error type // and returned to the caller. This includes usage text. -func Run(args []string, env config.Environment, file config.File, configFilePath string, cf APIClientFactory, httpClient api.HTTPClient, cliVersioner update.Versioner, in io.Reader, out io.Writer) error { - // The globals will hold generally-applicable configuration parameters - // from a variety of sources, and is provided to each concrete command. - globals := config.Data{ - File: file, - Env: env, - Output: out, +func Exec(data *global.Data) error { + app := configureKingpin(data) + cmds := commands.Define(app, data) + command, commandName, err := processCommandInput(data, app, cmds) + if err != nil { + return err + } + + // Check for --json flag early and set quiet mode if found. + if slices.Contains(data.Args, "--json") { + data.Flags.Quiet = true + } + + // We short-circuit the execution for specific cases: + // + // - argparser.ArgsIsHelpJSON() == true + // - shell autocompletion flag provided + switch commandName { + case "help--format=json": + fallthrough + case "help--formatjson": + fallthrough + case "shell-autocomplete": + return nil + } + + metadataDisable, _ := strconv.ParseBool(data.Env.WasmMetadataDisable) + if !slices.Contains(data.Args, "--metadata-disable") && !metadataDisable && !data.Config.CLI.MetadataNoticeDisplayed && commandCollectsData(commandName) && !data.Flags.Quiet { + text.Important(data.Output, "The Fastly CLI is configured to collect data related to Wasm builds (e.g. compilation times, resource usage, and other non-identifying data). To learn more about what data is being collected, why, and how to disable it: https://www.fastly.com/documentation/reference/cli") + text.Break(data.Output) + data.Config.CLI.MetadataNoticeDisplayed = true + err := data.Config.Write(data.ConfigPath) + if err != nil { + return fmt.Errorf("failed to persist change to metadata notice: %w", err) + } + time.Sleep(5 * time.Second) // this message is only displayed once so give the user a chance to see it before it possibly scrolls off screen + } + + if data.Flags.Quiet { + data.Manifest.File.SetQuiet(true) + } + + apiEndpoint, endpointSource := data.APIEndpoint() + if data.Verbose() { + displayAPIEndpoint(apiEndpoint, endpointSource, data.Output) + } + + // User can set env.DebugMode env var or the --debug-mode boolean flag. + // This will prioritise the flag over the env var. + if data.Flags.Debug { + data.Env.DebugMode = "true" + } + + // NOTE: Some commands need just the auth server to be running. + // But not necessarily need to process an existing token. + // e.g. `profile create example_sso_user --sso` + // Which needs the auth server so it can start up an OAuth flow. + if !commandRequiresToken(command) && commandRequiresAuthServer(commandName) { + // NOTE: Checking for nil allows our test suite to mock the server. + // i.e. it'll be nil whenever the CLI is run by a user but not `go test`. + if data.AuthServer == nil { + authServer, err := configureAuth(apiEndpoint, data.Args, data.Config, data.HTTPClient, data.Env) + if err != nil { + return fmt.Errorf("failed to configure authentication processes: %w", err) + } + data.AuthServer = authServer + } + } + + if commandRequiresToken(command) { + // NOTE: Checking for nil allows our test suite to mock the server. + // i.e. it'll be nil whenever the CLI is run by a user but not `go test`. + if data.AuthServer == nil { + authServer, err := configureAuth(apiEndpoint, data.Args, data.Config, data.HTTPClient, data.Env) + if err != nil { + return fmt.Errorf("failed to configure authentication processes: %w", err) + } + data.AuthServer = authServer + } + + token, tokenSource, err := processToken(cmds, data) + if err != nil { + if errors.Is(err, fsterr.ErrDontContinue) { + return nil // we shouldn't exit 1 if user chooses to stop + } + return fmt.Errorf("failed to process token: %w", err) + } + + if data.Verbose() { + displayToken(tokenSource, data) + } + if !data.Flags.Quiet { + checkConfigPermissions(commandName, tokenSource, data.Output) + } + + data.APIClient, data.RTSClient, err = configureClients(token, apiEndpoint, data.APIClientFactory, data.Flags.Debug) + if err != nil { + data.ErrLog.Add(err) + return fmt.Errorf("error constructing client: %w", err) + } } + f := checkForUpdates(data.Versioners.CLI, commandName, data.Flags.Quiet) + defer f(data.Output) + + return command.Exec(data.Input, data.Output) +} + +func configureKingpin(data *global.Data) *kingpin.Application { // Set up the main application root, including global flags, and then each // of the subcommands. Note that we deliberately don't use some of the more // advanced features of the kingpin.Application flags, like env var // bindings, because we need to do things like track where a config // parameter came from. app := kingpin.New("fastly", "A tool to interact with the Fastly API") - app.Writers(out, io.Discard) // don't let kingpin write error output + app.Writers(data.Output, io.Discard) // don't let kingpin write error output app.UsageContext(&kingpin.UsageContext{ Template: VerboseUsageTemplate, Funcs: UsageTemplateFuncs, @@ -98,683 +304,446 @@ func Run(args []string, env config.Environment, file config.File, configFilePath // error states and output control flow. app.Terminate(nil) - // As kingpin generates bash completion as a side-effect of kingpin.Parse we - // allow it to call os.Exit, only if a completetion flag is present. - if isCompletion(args) { - app.Terminate(os.Exit) - } - - // WARNING: kingping has no way of decorating flags as being "global" - // therefore if you add/remove a global flag you will also need to update - // the globalFlag map in pkg/app/usage.go which is used for usage rendering. - tokenHelp := fmt.Sprintf("Fastly API token (or via %s)", config.EnvVarToken) - app.Flag("token", tokenHelp).Short('t').StringVar(&globals.Flag.Token) - app.Flag("verbose", "Verbose logging").Short('v').BoolVar(&globals.Flag.Verbose) - app.Flag("endpoint", "Fastly API endpoint").Hidden().StringVar(&globals.Flag.Endpoint) - - configureRoot := configure.NewRootCommand(app, configFilePath, configure.APIClientFactory(cf), &globals) - whoamiRoot := whoami.NewRootCommand(app, httpClient, &globals) - versionRoot := version.NewRootCommand(app) - updateRoot := update.NewRootCommand(app, configFilePath, cliVersioner, httpClient, &globals) - - serviceRoot := service.NewRootCommand(app, &globals) - serviceCreate := service.NewCreateCommand(serviceRoot.CmdClause, &globals) - serviceList := service.NewListCommand(serviceRoot.CmdClause, &globals) - serviceDescribe := service.NewDescribeCommand(serviceRoot.CmdClause, &globals) - serviceUpdate := service.NewUpdateCommand(serviceRoot.CmdClause, &globals) - serviceDelete := service.NewDeleteCommand(serviceRoot.CmdClause, &globals) - serviceSearch := service.NewSearchCommand(serviceRoot.CmdClause, &globals) - - serviceVersionRoot := serviceversion.NewRootCommand(app, &globals) - serviceVersionClone := serviceversion.NewCloneCommand(serviceVersionRoot.CmdClause, &globals) - serviceVersionList := serviceversion.NewListCommand(serviceVersionRoot.CmdClause, &globals) - serviceVersionUpdate := serviceversion.NewUpdateCommand(serviceVersionRoot.CmdClause, &globals) - serviceVersionActivate := serviceversion.NewActivateCommand(serviceVersionRoot.CmdClause, &globals) - serviceVersionDeactivate := serviceversion.NewDeactivateCommand(serviceVersionRoot.CmdClause, &globals) - serviceVersionLock := serviceversion.NewLockCommand(serviceVersionRoot.CmdClause, &globals) - - computeRoot := compute.NewRootCommand(app, &globals) - computeInit := compute.NewInitCommand(computeRoot.CmdClause, httpClient, &globals) - computeBuild := compute.NewBuildCommand(computeRoot.CmdClause, httpClient, &globals) - computeDeploy := compute.NewDeployCommand(computeRoot.CmdClause, httpClient, &globals) - computePublish := compute.NewPublishCommand(computeRoot.CmdClause, &globals, computeBuild, computeDeploy) - computeUpdate := compute.NewUpdateCommand(computeRoot.CmdClause, httpClient, &globals) - computeValidate := compute.NewValidateCommand(computeRoot.CmdClause, &globals) - - domainRoot := domain.NewRootCommand(app, &globals) - domainCreate := domain.NewCreateCommand(domainRoot.CmdClause, &globals) - domainList := domain.NewListCommand(domainRoot.CmdClause, &globals) - domainDescribe := domain.NewDescribeCommand(domainRoot.CmdClause, &globals) - domainUpdate := domain.NewUpdateCommand(domainRoot.CmdClause, &globals) - domainDelete := domain.NewDeleteCommand(domainRoot.CmdClause, &globals) - - backendRoot := backend.NewRootCommand(app, &globals) - backendCreate := backend.NewCreateCommand(backendRoot.CmdClause, &globals) - backendList := backend.NewListCommand(backendRoot.CmdClause, &globals) - backendDescribe := backend.NewDescribeCommand(backendRoot.CmdClause, &globals) - backendUpdate := backend.NewUpdateCommand(backendRoot.CmdClause, &globals) - backendDelete := backend.NewDeleteCommand(backendRoot.CmdClause, &globals) - - healthcheckRoot := healthcheck.NewRootCommand(app, &globals) - healthcheckCreate := healthcheck.NewCreateCommand(healthcheckRoot.CmdClause, &globals) - healthcheckList := healthcheck.NewListCommand(healthcheckRoot.CmdClause, &globals) - healthcheckDescribe := healthcheck.NewDescribeCommand(healthcheckRoot.CmdClause, &globals) - healthcheckUpdate := healthcheck.NewUpdateCommand(healthcheckRoot.CmdClause, &globals) - healthcheckDelete := healthcheck.NewDeleteCommand(healthcheckRoot.CmdClause, &globals) - - dictionaryRoot := edgedictionary.NewRootCommand(app, &globals) - dictionaryCreate := edgedictionary.NewCreateCommand(dictionaryRoot.CmdClause, &globals) - dictionaryDescribe := edgedictionary.NewDescribeCommand(dictionaryRoot.CmdClause, &globals) - dictionaryDelete := edgedictionary.NewDeleteCommand(dictionaryRoot.CmdClause, &globals) - dictionaryList := edgedictionary.NewListCommand(dictionaryRoot.CmdClause, &globals) - dictionaryUpdate := edgedictionary.NewUpdateCommand(dictionaryRoot.CmdClause, &globals) - - dictionaryItemRoot := edgedictionaryitem.NewRootCommand(app, &globals) - dictionaryItemList := edgedictionaryitem.NewListCommand(dictionaryItemRoot.CmdClause, &globals) - dictionaryItemDescribe := edgedictionaryitem.NewDescribeCommand(dictionaryItemRoot.CmdClause, &globals) - dictionaryItemCreate := edgedictionaryitem.NewCreateCommand(dictionaryItemRoot.CmdClause, &globals) - dictionaryItemUpdate := edgedictionaryitem.NewUpdateCommand(dictionaryItemRoot.CmdClause, &globals) - dictionaryItemDelete := edgedictionaryitem.NewDeleteCommand(dictionaryItemRoot.CmdClause, &globals) - dictionaryItemBatchModify := edgedictionaryitem.NewBatchCommand(dictionaryItemRoot.CmdClause, &globals) - - loggingRoot := logging.NewRootCommand(app, &globals) - - bigQueryRoot := bigquery.NewRootCommand(loggingRoot.CmdClause, &globals) - bigQueryCreate := bigquery.NewCreateCommand(bigQueryRoot.CmdClause, &globals) - bigQueryList := bigquery.NewListCommand(bigQueryRoot.CmdClause, &globals) - bigQueryDescribe := bigquery.NewDescribeCommand(bigQueryRoot.CmdClause, &globals) - bigQueryUpdate := bigquery.NewUpdateCommand(bigQueryRoot.CmdClause, &globals) - bigQueryDelete := bigquery.NewDeleteCommand(bigQueryRoot.CmdClause, &globals) - - s3Root := s3.NewRootCommand(loggingRoot.CmdClause, &globals) - s3Create := s3.NewCreateCommand(s3Root.CmdClause, &globals) - s3List := s3.NewListCommand(s3Root.CmdClause, &globals) - s3Describe := s3.NewDescribeCommand(s3Root.CmdClause, &globals) - s3Update := s3.NewUpdateCommand(s3Root.CmdClause, &globals) - s3Delete := s3.NewDeleteCommand(s3Root.CmdClause, &globals) - - kinesisRoot := kinesis.NewRootCommand(loggingRoot.CmdClause, &globals) - kinesisCreate := kinesis.NewCreateCommand(kinesisRoot.CmdClause, &globals) - kinesisList := kinesis.NewListCommand(kinesisRoot.CmdClause, &globals) - kinesisDescribe := kinesis.NewDescribeCommand(kinesisRoot.CmdClause, &globals) - kinesisUpdate := kinesis.NewUpdateCommand(kinesisRoot.CmdClause, &globals) - kinesisDelete := kinesis.NewDeleteCommand(kinesisRoot.CmdClause, &globals) - - syslogRoot := syslog.NewRootCommand(loggingRoot.CmdClause, &globals) - syslogCreate := syslog.NewCreateCommand(syslogRoot.CmdClause, &globals) - syslogList := syslog.NewListCommand(syslogRoot.CmdClause, &globals) - syslogDescribe := syslog.NewDescribeCommand(syslogRoot.CmdClause, &globals) - syslogUpdate := syslog.NewUpdateCommand(syslogRoot.CmdClause, &globals) - syslogDelete := syslog.NewDeleteCommand(syslogRoot.CmdClause, &globals) - - logentriesRoot := logentries.NewRootCommand(loggingRoot.CmdClause, &globals) - logentriesCreate := logentries.NewCreateCommand(logentriesRoot.CmdClause, &globals) - logentriesList := logentries.NewListCommand(logentriesRoot.CmdClause, &globals) - logentriesDescribe := logentries.NewDescribeCommand(logentriesRoot.CmdClause, &globals) - logentriesUpdate := logentries.NewUpdateCommand(logentriesRoot.CmdClause, &globals) - logentriesDelete := logentries.NewDeleteCommand(logentriesRoot.CmdClause, &globals) - - papertrailRoot := papertrail.NewRootCommand(loggingRoot.CmdClause, &globals) - papertrailCreate := papertrail.NewCreateCommand(papertrailRoot.CmdClause, &globals) - papertrailList := papertrail.NewListCommand(papertrailRoot.CmdClause, &globals) - papertrailDescribe := papertrail.NewDescribeCommand(papertrailRoot.CmdClause, &globals) - papertrailUpdate := papertrail.NewUpdateCommand(papertrailRoot.CmdClause, &globals) - papertrailDelete := papertrail.NewDeleteCommand(papertrailRoot.CmdClause, &globals) - - sumologicRoot := sumologic.NewRootCommand(loggingRoot.CmdClause, &globals) - sumologicCreate := sumologic.NewCreateCommand(sumologicRoot.CmdClause, &globals) - sumologicList := sumologic.NewListCommand(sumologicRoot.CmdClause, &globals) - sumologicDescribe := sumologic.NewDescribeCommand(sumologicRoot.CmdClause, &globals) - sumologicUpdate := sumologic.NewUpdateCommand(sumologicRoot.CmdClause, &globals) - sumologicDelete := sumologic.NewDeleteCommand(sumologicRoot.CmdClause, &globals) - - gcsRoot := gcs.NewRootCommand(loggingRoot.CmdClause, &globals) - gcsCreate := gcs.NewCreateCommand(gcsRoot.CmdClause, &globals) - gcsList := gcs.NewListCommand(gcsRoot.CmdClause, &globals) - gcsDescribe := gcs.NewDescribeCommand(gcsRoot.CmdClause, &globals) - gcsUpdate := gcs.NewUpdateCommand(gcsRoot.CmdClause, &globals) - gcsDelete := gcs.NewDeleteCommand(gcsRoot.CmdClause, &globals) - - ftpRoot := ftp.NewRootCommand(loggingRoot.CmdClause, &globals) - ftpCreate := ftp.NewCreateCommand(ftpRoot.CmdClause, &globals) - ftpList := ftp.NewListCommand(ftpRoot.CmdClause, &globals) - ftpDescribe := ftp.NewDescribeCommand(ftpRoot.CmdClause, &globals) - ftpUpdate := ftp.NewUpdateCommand(ftpRoot.CmdClause, &globals) - ftpDelete := ftp.NewDeleteCommand(ftpRoot.CmdClause, &globals) - - splunkRoot := splunk.NewRootCommand(loggingRoot.CmdClause, &globals) - splunkCreate := splunk.NewCreateCommand(splunkRoot.CmdClause, &globals) - splunkList := splunk.NewListCommand(splunkRoot.CmdClause, &globals) - splunkDescribe := splunk.NewDescribeCommand(splunkRoot.CmdClause, &globals) - splunkUpdate := splunk.NewUpdateCommand(splunkRoot.CmdClause, &globals) - splunkDelete := splunk.NewDeleteCommand(splunkRoot.CmdClause, &globals) - - scalyrRoot := scalyr.NewRootCommand(loggingRoot.CmdClause, &globals) - scalyrCreate := scalyr.NewCreateCommand(scalyrRoot.CmdClause, &globals) - scalyrList := scalyr.NewListCommand(scalyrRoot.CmdClause, &globals) - scalyrDescribe := scalyr.NewDescribeCommand(scalyrRoot.CmdClause, &globals) - scalyrUpdate := scalyr.NewUpdateCommand(scalyrRoot.CmdClause, &globals) - scalyrDelete := scalyr.NewDeleteCommand(scalyrRoot.CmdClause, &globals) - - logglyRoot := loggly.NewRootCommand(loggingRoot.CmdClause, &globals) - logglyCreate := loggly.NewCreateCommand(logglyRoot.CmdClause, &globals) - logglyList := loggly.NewListCommand(logglyRoot.CmdClause, &globals) - logglyDescribe := loggly.NewDescribeCommand(logglyRoot.CmdClause, &globals) - logglyUpdate := loggly.NewUpdateCommand(logglyRoot.CmdClause, &globals) - logglyDelete := loggly.NewDeleteCommand(logglyRoot.CmdClause, &globals) - - honeycombRoot := honeycomb.NewRootCommand(loggingRoot.CmdClause, &globals) - honeycombCreate := honeycomb.NewCreateCommand(honeycombRoot.CmdClause, &globals) - honeycombList := honeycomb.NewListCommand(honeycombRoot.CmdClause, &globals) - honeycombDescribe := honeycomb.NewDescribeCommand(honeycombRoot.CmdClause, &globals) - honeycombUpdate := honeycomb.NewUpdateCommand(honeycombRoot.CmdClause, &globals) - honeycombDelete := honeycomb.NewDeleteCommand(honeycombRoot.CmdClause, &globals) - - herokuRoot := heroku.NewRootCommand(loggingRoot.CmdClause, &globals) - herokuCreate := heroku.NewCreateCommand(herokuRoot.CmdClause, &globals) - herokuList := heroku.NewListCommand(herokuRoot.CmdClause, &globals) - herokuDescribe := heroku.NewDescribeCommand(herokuRoot.CmdClause, &globals) - herokuUpdate := heroku.NewUpdateCommand(herokuRoot.CmdClause, &globals) - herokuDelete := heroku.NewDeleteCommand(herokuRoot.CmdClause, &globals) - - sftpRoot := sftp.NewRootCommand(loggingRoot.CmdClause, &globals) - sftpCreate := sftp.NewCreateCommand(sftpRoot.CmdClause, &globals) - sftpList := sftp.NewListCommand(sftpRoot.CmdClause, &globals) - sftpDescribe := sftp.NewDescribeCommand(sftpRoot.CmdClause, &globals) - sftpUpdate := sftp.NewUpdateCommand(sftpRoot.CmdClause, &globals) - sftpDelete := sftp.NewDeleteCommand(sftpRoot.CmdClause, &globals) - - logshuttleRoot := logshuttle.NewRootCommand(loggingRoot.CmdClause, &globals) - logshuttleCreate := logshuttle.NewCreateCommand(logshuttleRoot.CmdClause, &globals) - logshuttleList := logshuttle.NewListCommand(logshuttleRoot.CmdClause, &globals) - logshuttleDescribe := logshuttle.NewDescribeCommand(logshuttleRoot.CmdClause, &globals) - logshuttleUpdate := logshuttle.NewUpdateCommand(logshuttleRoot.CmdClause, &globals) - logshuttleDelete := logshuttle.NewDeleteCommand(logshuttleRoot.CmdClause, &globals) - - cloudfilesRoot := cloudfiles.NewRootCommand(loggingRoot.CmdClause, &globals) - cloudfilesCreate := cloudfiles.NewCreateCommand(cloudfilesRoot.CmdClause, &globals) - cloudfilesList := cloudfiles.NewListCommand(cloudfilesRoot.CmdClause, &globals) - cloudfilesDescribe := cloudfiles.NewDescribeCommand(cloudfilesRoot.CmdClause, &globals) - cloudfilesUpdate := cloudfiles.NewUpdateCommand(cloudfilesRoot.CmdClause, &globals) - cloudfilesDelete := cloudfiles.NewDeleteCommand(cloudfilesRoot.CmdClause, &globals) - - digitaloceanRoot := digitalocean.NewRootCommand(loggingRoot.CmdClause, &globals) - digitaloceanCreate := digitalocean.NewCreateCommand(digitaloceanRoot.CmdClause, &globals) - digitaloceanList := digitalocean.NewListCommand(digitaloceanRoot.CmdClause, &globals) - digitaloceanDescribe := digitalocean.NewDescribeCommand(digitaloceanRoot.CmdClause, &globals) - digitaloceanUpdate := digitalocean.NewUpdateCommand(digitaloceanRoot.CmdClause, &globals) - digitaloceanDelete := digitalocean.NewDeleteCommand(digitaloceanRoot.CmdClause, &globals) - - elasticsearchRoot := elasticsearch.NewRootCommand(loggingRoot.CmdClause, &globals) - elasticsearchCreate := elasticsearch.NewCreateCommand(elasticsearchRoot.CmdClause, &globals) - elasticsearchList := elasticsearch.NewListCommand(elasticsearchRoot.CmdClause, &globals) - elasticsearchDescribe := elasticsearch.NewDescribeCommand(elasticsearchRoot.CmdClause, &globals) - elasticsearchUpdate := elasticsearch.NewUpdateCommand(elasticsearchRoot.CmdClause, &globals) - elasticsearchDelete := elasticsearch.NewDeleteCommand(elasticsearchRoot.CmdClause, &globals) - - azureblobRoot := azureblob.NewRootCommand(loggingRoot.CmdClause, &globals) - azureblobCreate := azureblob.NewCreateCommand(azureblobRoot.CmdClause, &globals) - azureblobList := azureblob.NewListCommand(azureblobRoot.CmdClause, &globals) - azureblobDescribe := azureblob.NewDescribeCommand(azureblobRoot.CmdClause, &globals) - azureblobUpdate := azureblob.NewUpdateCommand(azureblobRoot.CmdClause, &globals) - azureblobDelete := azureblob.NewDeleteCommand(azureblobRoot.CmdClause, &globals) - - datadogRoot := datadog.NewRootCommand(loggingRoot.CmdClause, &globals) - datadogCreate := datadog.NewCreateCommand(datadogRoot.CmdClause, &globals) - datadogList := datadog.NewListCommand(datadogRoot.CmdClause, &globals) - datadogDescribe := datadog.NewDescribeCommand(datadogRoot.CmdClause, &globals) - datadogUpdate := datadog.NewUpdateCommand(datadogRoot.CmdClause, &globals) - datadogDelete := datadog.NewDeleteCommand(datadogRoot.CmdClause, &globals) - - httpsRoot := https.NewRootCommand(loggingRoot.CmdClause, &globals) - httpsCreate := https.NewCreateCommand(httpsRoot.CmdClause, &globals) - httpsList := https.NewListCommand(httpsRoot.CmdClause, &globals) - httpsDescribe := https.NewDescribeCommand(httpsRoot.CmdClause, &globals) - httpsUpdate := https.NewUpdateCommand(httpsRoot.CmdClause, &globals) - httpsDelete := https.NewDeleteCommand(httpsRoot.CmdClause, &globals) - - kafkaRoot := kafka.NewRootCommand(loggingRoot.CmdClause, &globals) - kafkaCreate := kafka.NewCreateCommand(kafkaRoot.CmdClause, &globals) - kafkaList := kafka.NewListCommand(kafkaRoot.CmdClause, &globals) - kafkaDescribe := kafka.NewDescribeCommand(kafkaRoot.CmdClause, &globals) - kafkaUpdate := kafka.NewUpdateCommand(kafkaRoot.CmdClause, &globals) - kafkaDelete := kafka.NewDeleteCommand(kafkaRoot.CmdClause, &globals) - - googlepubsubRoot := googlepubsub.NewRootCommand(loggingRoot.CmdClause, &globals) - googlepubsubCreate := googlepubsub.NewCreateCommand(googlepubsubRoot.CmdClause, &globals) - googlepubsubList := googlepubsub.NewListCommand(googlepubsubRoot.CmdClause, &globals) - googlepubsubDescribe := googlepubsub.NewDescribeCommand(googlepubsubRoot.CmdClause, &globals) - googlepubsubUpdate := googlepubsub.NewUpdateCommand(googlepubsubRoot.CmdClause, &globals) - googlepubsubDelete := googlepubsub.NewDeleteCommand(googlepubsubRoot.CmdClause, &globals) - - openstackRoot := openstack.NewRootCommand(loggingRoot.CmdClause, &globals) - openstackCreate := openstack.NewCreateCommand(openstackRoot.CmdClause, &globals) - openstackList := openstack.NewListCommand(openstackRoot.CmdClause, &globals) - openstackDescribe := openstack.NewDescribeCommand(openstackRoot.CmdClause, &globals) - openstackUpdate := openstack.NewUpdateCommand(openstackRoot.CmdClause, &globals) - openstackDelete := openstack.NewDeleteCommand(openstackRoot.CmdClause, &globals) - - logsRoot := logs.NewRootCommand(app, &globals) - logsTail := logs.NewTailCommand(logsRoot.CmdClause, &globals) - - statsRoot := stats.NewRootCommand(app, &globals) - statsRegions := stats.NewRegionsCommand(statsRoot.CmdClause, &globals) - statsHistorical := stats.NewHistoricalCommand(statsRoot.CmdClause, &globals) - statsRealtime := stats.NewRealtimeCommand(statsRoot.CmdClause, &globals) - - commands := []common.Command{ - configureRoot, - whoamiRoot, - versionRoot, - updateRoot, - - serviceRoot, - serviceCreate, - serviceList, - serviceDescribe, - serviceUpdate, - serviceDelete, - serviceSearch, - - serviceVersionRoot, - serviceVersionClone, - serviceVersionList, - serviceVersionUpdate, - serviceVersionActivate, - serviceVersionDeactivate, - serviceVersionLock, - - computeRoot, - computeInit, - computeBuild, - computeDeploy, - computePublish, - computeUpdate, - computeValidate, - - domainRoot, - domainCreate, - domainList, - domainDescribe, - domainUpdate, - domainDelete, - - backendRoot, - backendCreate, - backendList, - backendDescribe, - backendUpdate, - backendDelete, - - healthcheckRoot, - healthcheckCreate, - healthcheckList, - healthcheckDescribe, - healthcheckUpdate, - healthcheckDelete, - - dictionaryRoot, - dictionaryCreate, - dictionaryDescribe, - dictionaryDelete, - dictionaryList, - dictionaryUpdate, - - dictionaryItemRoot, - dictionaryItemList, - dictionaryItemDescribe, - dictionaryItemCreate, - dictionaryItemUpdate, - dictionaryItemDelete, - dictionaryItemBatchModify, - - loggingRoot, - - bigQueryRoot, - bigQueryCreate, - bigQueryList, - bigQueryDescribe, - bigQueryUpdate, - bigQueryDelete, - - s3Root, - s3Create, - s3List, - s3Describe, - s3Update, - s3Delete, - - kinesisRoot, - kinesisCreate, - kinesisList, - kinesisDescribe, - kinesisUpdate, - kinesisDelete, - - syslogRoot, - syslogCreate, - syslogList, - syslogDescribe, - syslogUpdate, - syslogDelete, - - logentriesRoot, - logentriesCreate, - logentriesList, - logentriesDescribe, - logentriesUpdate, - logentriesDelete, - - papertrailRoot, - papertrailCreate, - papertrailList, - papertrailDescribe, - papertrailUpdate, - papertrailDelete, - - sumologicRoot, - sumologicCreate, - sumologicList, - sumologicDescribe, - sumologicUpdate, - sumologicDelete, - - gcsRoot, - gcsCreate, - gcsList, - gcsDescribe, - gcsUpdate, - gcsDelete, - - ftpRoot, - ftpCreate, - ftpList, - ftpDescribe, - ftpUpdate, - ftpDelete, - - splunkRoot, - splunkCreate, - splunkList, - splunkDescribe, - splunkUpdate, - splunkDelete, - - scalyrRoot, - scalyrCreate, - scalyrList, - scalyrDescribe, - scalyrUpdate, - scalyrDelete, - - logglyRoot, - logglyCreate, - logglyList, - logglyDescribe, - logglyUpdate, - logglyDelete, - - honeycombRoot, - honeycombCreate, - honeycombList, - honeycombDescribe, - honeycombUpdate, - honeycombDelete, - - herokuRoot, - herokuCreate, - herokuList, - herokuDescribe, - herokuUpdate, - herokuDelete, - - sftpRoot, - sftpCreate, - sftpList, - sftpDescribe, - sftpUpdate, - sftpDelete, - - logshuttleRoot, - logshuttleCreate, - logshuttleList, - logshuttleDescribe, - logshuttleUpdate, - logshuttleDelete, - - cloudfilesRoot, - cloudfilesCreate, - cloudfilesList, - cloudfilesDescribe, - cloudfilesUpdate, - cloudfilesDelete, - - digitaloceanRoot, - digitaloceanCreate, - digitaloceanList, - digitaloceanDescribe, - digitaloceanUpdate, - digitaloceanDelete, - - elasticsearchRoot, - elasticsearchCreate, - elasticsearchList, - elasticsearchDescribe, - elasticsearchUpdate, - elasticsearchDelete, - - azureblobRoot, - azureblobCreate, - azureblobList, - azureblobDescribe, - azureblobUpdate, - azureblobDelete, - - datadogRoot, - datadogCreate, - datadogList, - datadogDescribe, - datadogUpdate, - datadogDelete, - - httpsRoot, - httpsCreate, - httpsList, - httpsDescribe, - httpsUpdate, - httpsDelete, - - kafkaRoot, - kafkaCreate, - kafkaList, - kafkaDescribe, - kafkaUpdate, - kafkaDelete, - - googlepubsubRoot, - googlepubsubCreate, - googlepubsubList, - googlepubsubDescribe, - googlepubsubUpdate, - googlepubsubDelete, - - openstackRoot, - openstackCreate, - openstackList, - openstackDescribe, - openstackUpdate, - openstackDelete, - - logsRoot, - logsTail, - - statsRoot, - statsRegions, - statsHistorical, - statsRealtime, - } - - // Handle parse errors and display contextal usage if possible. Due to bugs - // and an obession for lots of output side-effects in the kingpin.Parse - // logic, we suppress it from writing any usage or errors to the writer by - // swapping the writer with a no-op and then restoring the real writer - // afterwards. This ensures usage text is only written once to the writer - // and gives us greater control over our error formatting. - app.Writers(io.Discard, io.Discard) - name, err := app.Parse(args) - if err != nil && !argsIsHelpJSON(args) { // Ignore error if `help --format json` - usage := Usage(args, app, out, io.Discard) - return errors.RemediationError{Prefix: usage, Inner: fmt.Errorf("error parsing arguments: %w", err)} - } - if ctx, _ := app.ParseContext(args); contextHasHelpFlag(ctx) { - usage := Usage(args, app, out, io.Discard) - return errors.RemediationError{Prefix: usage} - } - app.Writers(out, io.Discard) - - // As the `help` command model gets privately added as a side-effect of - // kingping.Parse, we cannot add the `--format json` flag to the model. - // Therefore, we have to manually parse the args slice here to check for the - // existence of `help --format json`, if present we print usage JSON and - // exit early. - if argsIsHelpJSON(args) { - json, err := UsageJSON(app) + // IMPORTANT: Kingpin doesn't support global flags. + // Any flags defined below must also be added to two other places: + // 1. ./usage.go (`globalFlags` map). + // 2. ../cmd/argparser.go (`IsGlobalFlagsOnly` function). + // + // NOTE: Global flags (long and short) MUST be unique. + // A subcommand can't define a flag that is already global. + // Kingpin will otherwise trigger a runtime panic 🎉 + // Interestingly, short flags can be reused but only across subcommands. + tokenHelp := fmt.Sprintf("Fastly API token (or via %s)", env.APIToken) + app.Flag("accept-defaults", "Accept default options for all interactive prompts apart from Yes/No confirmations").Short('d').BoolVar(&data.Flags.AcceptDefaults) + app.Flag("account", "Fastly Accounts endpoint").Hidden().StringVar(&data.Flags.AccountEndpoint) + app.Flag("api", "Fastly API endpoint").Hidden().StringVar(&data.Flags.APIEndpoint) + app.Flag("auto-yes", "Answer yes automatically to all Yes/No confirmations. This may suppress security warnings").Short('y').BoolVar(&data.Flags.AutoYes) + // IMPORTANT: `--debug` is a built-in Kingpin flag so we must use `debug-mode`. + app.Flag("debug-mode", "Print API request and response details (NOTE: can disrupt the normal CLI flow output formatting)").BoolVar(&data.Flags.Debug) + // IMPORTANT: `--sso` causes a Kingpin runtime panic 🤦 so we use `enable-sso`. + app.Flag("enable-sso", "Enable Single-Sign On (SSO) for current profile execution (see also: 'fastly sso')").BoolVar(&data.Flags.SSO) + app.Flag("non-interactive", "Do not prompt for user input - suitable for CI processes. Equivalent to --accept-defaults and --auto-yes").Short('i').BoolVar(&data.Flags.NonInteractive) + app.Flag("profile", "Switch account profile for single command execution (see also: 'fastly profile switch')").Short('o').StringVar(&data.Flags.Profile) + app.Flag("quiet", "Silence all output except direct command output. This won't prevent interactive prompts (see: --accept-defaults, --auto-yes, --non-interactive)").Short('q').BoolVar(&data.Flags.Quiet) + app.Flag("token", tokenHelp).HintAction(env.Vars).Short('t').StringVar(&data.Flags.Token) + app.Flag("verbose", "Verbose logging").Short('v').BoolVar(&data.Flags.Verbose) + + return app +} + +// processToken handles all aspects related to the required API token. +// +// First we check if a profile token is defined in config and if so, we will +// validate if it has expired, and if it has we will attempt to refresh it. +// +// If both the access token and the refresh token has expired we will trigger +// the `fastly sso` command to execute. +// +// Either way, the CLI config is updated to reflect the token that was either +// refreshed or regenerated from the authentication process. +// +// Next, we check the config file's permissions. +// +// Finally, we check if there is a profile override in place (e.g. set via the +// --profile flag or using the `profile` field in the fastly.toml manifest). +func processToken(cmds []argparser.Command, data *global.Data) (token string, tokenSource lookup.Source, err error) { + token, tokenSource = data.Token() + + // Check if token is from a profile. + // e.g. --profile, fastly.toml override, or config default profile. + // If it is, then we'll check if it is expired. + // + // NOTE: tokens via FASTLY_API_TOKEN or --token aren't checked for a TTL. + // This is because we don't have them persisted to disk. + // Meaning we can't check for a TTL or access an access/refresh token. + // So we have to presume those overrides are using a long-lived token. + switch tokenSource { + case lookup.SourceFile: + profileName, profileData, err := data.Profile() if err != nil { - return err + return "", tokenSource, err } - fmt.Fprintf(out, "%s", json) - return nil + // User with long-lived token will skip SSO if they've not enabled it. + if shouldSkipSSO(profileName, profileData, data) { + return token, tokenSource, nil + } + // User now either has an existing SSO-based token or they want to migrate. + // If a long-lived token, then trigger SSO. + if auth.IsLongLivedToken(profileData) { + return ssoAuthentication("You've not authenticated via OAuth before", cmds, data) + } + // Otherwise, for an existing SSO token, check its freshness. + reauth, err := checkAndRefreshSSOToken(profileData, profileName, data) + if err != nil { + // The following scenario is when the user wants to switch to another SSO + // profile that exists under a different auth session. + if errors.Is(err, auth.ErrInvalidGrant) { + sso.ForceReAuth = true + return ssoAuthentication("We can't refresh your token", cmds, data) + } + return token, tokenSource, fmt.Errorf("failed to check access/refresh token: %w", err) + } + if reauth { + return ssoAuthentication("Your access token has expired and so has your refresh token", cmds, data) + } + case lookup.SourceUndefined: + // If there's no token available, then trigger SSO authentication flow. + // + // FIXME: Remove this conditional when SSO is GA. + // Once put back, it means "no token" == "automatic SSO". + // For now, a brand new CLI user will have to manually create long-lived + // tokens via the UI in order to use the Fastly CLI. + if data.Env.UseSSO != "1" && !data.Flags.SSO { + return "", tokenSource, nil + } + return ssoAuthentication("No API token could be found", cmds, data) + case lookup.SourceEnvironment, lookup.SourceFlag, lookup.SourceDefault: + // no-op } - // A side-effect of suppressing app.Parse from writing output is the usage - // isn't printed for the default `help` command. Therefore we capture it - // here by calling Parse, again swapping the Writers. This also ensures the - // larger and more verbose help formatting is used. - if name == "help" { - var buf bytes.Buffer - app.Writers(&buf, io.Discard) - app.Parse(args) - app.Writers(out, io.Discard) - - // The full-fat output of `fastly help` should have a hint at the bottom - // for more specific help. Unfortunately I don't know of a better way to - // distinguish `fastly help` from e.g. `fastly help configure` than this - // check. - if len(args) > 0 && args[len(args)-1] == "help" { - fmt.Fprintln(&buf, "For help on a specific command, try e.g.") - fmt.Fprintln(&buf, "") - fmt.Fprintln(&buf, "\tfastly help configure") - fmt.Fprintln(&buf, "\tfastly configure --help") - fmt.Fprintln(&buf, "") + return token, tokenSource, nil +} + +// checkAndRefreshSSOToken refreshes the access/refresh tokens if expired. +func checkAndRefreshSSOToken(profileData *config.Profile, profileName string, data *global.Data) (reauth bool, err error) { + // Access Token has expired + if auth.TokenExpired(profileData.AccessTokenTTL, profileData.AccessTokenCreated) { + // Refresh Token has expired + if auth.TokenExpired(profileData.RefreshTokenTTL, profileData.RefreshTokenCreated) { + return true, nil + } + + if data.Flags.Verbose { + text.Info(data.Output, "\nYour access token has now expired. We will attempt to refresh it") + } + + updatedJWT, err := data.AuthServer.RefreshAccessToken(profileData.RefreshToken) + if err != nil { + if errors.Is(err, auth.ErrInvalidGrant) { + return false, err + } + return false, fmt.Errorf("failed to refresh access token: %w", err) + } + + email, at, err := data.AuthServer.ValidateAndRetrieveAPIToken(updatedJWT.AccessToken) + if err != nil { + return false, fmt.Errorf("failed to validate JWT and retrieve API token: %w", err) + } + + // NOTE: The refresh token can sometimes be refreshed along with the access token. + // This happens all the time in my testing but according to what is + // spec'd this apparently is something that _might_ happen. + // So after we get the refreshed access token, we check to see if the + // refresh token that was returned by the API call has also changed when + // compared to the refresh token stored in the CLI config file. + current := profile.Get(profileName, data.Config.Profiles) + if current == nil { + return false, fmt.Errorf("failed to locate '%s' profile", profileName) } + now := time.Now().Unix() + refreshToken := current.RefreshToken + refreshTokenCreated := current.RefreshTokenCreated + refreshTokenTTL := current.RefreshTokenTTL + if current.RefreshToken != updatedJWT.RefreshToken { + if data.Flags.Verbose { + text.Info(data.Output, "Your refresh token was also updated") + text.Break(data.Output) + } + refreshToken = updatedJWT.RefreshToken + refreshTokenCreated = now + refreshTokenTTL = updatedJWT.RefreshExpiresIn + } + + ps, ok := profile.Edit(profileName, data.Config.Profiles, func(p *config.Profile) { + p.AccessToken = updatedJWT.AccessToken + p.AccessTokenCreated = now + p.AccessTokenTTL = updatedJWT.ExpiresIn + p.Email = email + p.RefreshToken = refreshToken + p.RefreshTokenCreated = refreshTokenCreated + p.RefreshTokenTTL = refreshTokenTTL + p.Token = at.AccessToken + }) + if !ok { + return false, fsterr.RemediationError{ + Inner: fmt.Errorf("failed to update '%s' profile with new token data", profileName), + Remediation: "Run `fastly sso` to retry.", + } + } + data.Config.Profiles = ps + if err := data.Config.Write(data.ConfigPath); err != nil { + data.ErrLog.Add(err) + return false, fmt.Errorf("error saving config file: %w", err) + } + } - return errors.RemediationError{Prefix: buf.String()} + return false, nil +} + +// shouldSkipSSO identifies if a config is a pre-v5 config and, if it is, +// informs the user how they can use the SSO flow. It checks if the SSO +// environment variable (or flag) has been set and enables the SSO flow if so. +func shouldSkipSSO(_ string, profileData *config.Profile, data *global.Data) bool { + if auth.IsLongLivedToken(profileData) { + // Skip SSO if user hasn't indicated they want to migrate. + return data.Env.UseSSO != "1" && !data.Flags.SSO + // FIXME: Put back messaging once SSO is GA. + // if data.Env.UseSSO == "1" || data.Flags.SSO { + // return false // don't skip SSO + // } + // if !data.Flags.Quiet { + // if data.Flags.Verbose { + // text.Break(data.Output) + // } + // text.Important(data.Output, "The Fastly API token used by the current '%s' profile is not a Fastly SSO (Single Sign-On) generated token. SSO-based tokens offer more security and convenience. To update your token, either set `FASTLY_USE_SSO=1` or pass `--enable-sso` before invoking the Fastly CLI. This will ensure the current profile is switched to using an SSO generated API token. Once the token has been switched over you no longer need to set `FASTLY_USE_SSO` for this profile (--token and FASTLY_API_TOKEN can still be used as overrides).\n\n", profileName) + // } + // return true // skip SSO } + return false // don't skip SSO +} - token, source := globals.Token() - if globals.Verbose() { - switch source { - case config.SourceFlag: - fmt.Fprintf(out, "Fastly API token provided via --token\n") - case config.SourceEnvironment: - fmt.Fprintf(out, "Fastly API token provided via %s\n", config.EnvVarToken) - case config.SourceFile: - fmt.Fprintf(out, "Fastly API token provided via config file\n") - default: - fmt.Fprintf(out, "Fastly API token not provided\n") +// ssoAuthentication executes the `sso` command to handle authentication. +func ssoAuthentication(outputMessage string, cmds []argparser.Command, data *global.Data) (token string, tokenSource lookup.Source, err error) { + for _, command := range cmds { + commandName := strings.Split(command.Name(), " ")[0] + if commandName == "sso" { + if !data.Flags.AutoYes && !data.Flags.NonInteractive { + if data.Verbose() { + text.Break(data.Output) + } + text.Important(data.Output, "%s. We need to open your browser to authenticate you.", outputMessage) + text.Break(data.Output) + cont, err := text.AskYesNo(data.Output, text.BoldYellow("Do you want to continue? [y/N]: "), data.Input) + text.Break(data.Output) + if err != nil { + return token, tokenSource, err + } + if !cont { + return token, tokenSource, fsterr.ErrDontContinue + } + } + + data.SkipAuthPrompt = true // skip the same prompt in `sso` command flow + err := command.Exec(data.Input, data.Output) + if err != nil { + return token, tokenSource, fmt.Errorf("failed to authenticate: %w", err) + } + text.Break(data.Output) + break } } - // If we are using the token from config file, check the files permissions - // to assert if they are not too open or have been altered outside of the - // application and warn if so. - if source == config.SourceFile && name != "configure" { + // Updated token should be persisted to disk after command.Exec() completes. + token, tokenSource = data.Token() + if tokenSource == lookup.SourceUndefined { + return token, tokenSource, fsterr.ErrNoToken + } + return token, tokenSource, nil +} + +func displayToken(tokenSource lookup.Source, data *global.Data) { + profileSource := determineProfile(data.Manifest.File.Profile, data.Flags.Profile, data.Config.Profiles) + + switch tokenSource { + case lookup.SourceFlag: + fmt.Fprintf(data.Output, "Fastly API token provided via --token\n\n") + case lookup.SourceEnvironment: + fmt.Fprintf(data.Output, "Fastly API token provided via %s\n\n", env.APIToken) + case lookup.SourceFile: + fmt.Fprintf(data.Output, "Fastly API token provided via config file (profile: %s)\n\n", profileSource) + case lookup.SourceUndefined, lookup.SourceDefault: + fallthrough + default: + fmt.Fprintf(data.Output, "Fastly API token not provided\n\n") + } +} + +// If we are using the token from config file, check the file's permissions +// to assert if they are not too open or have been altered outside of the +// application and warn if so. +func checkConfigPermissions(commandName string, tokenSource lookup.Source, out io.Writer) { + segs := strings.Split(commandName, " ") + if tokenSource == lookup.SourceFile && (len(segs) > 0 && segs[0] != "profile") { if fi, err := os.Stat(config.FilePath); err == nil { if mode := fi.Mode().Perm(); mode > config.FilePermissions { - text.Warning(out, "Unprotected configuration file.") - fmt.Fprintf(out, "Permissions %04o for '%s' are too open\n", mode, config.FilePath) - fmt.Fprintf(out, "It is recommended that your configuration file is NOT accessible by others.\n") - fmt.Fprintln(out) + text.Warning(out, "Unprotected configuration file.\n\n") + text.Output(out, "Permissions for '%s' are too open\n\n", config.FilePath) + text.Output(out, "It is recommended that your configuration file is NOT accessible by others.\n\n") } } } +} - endpoint, source := globals.Endpoint() - if globals.Verbose() { - switch source { - case config.SourceEnvironment: - fmt.Fprintf(out, "Fastly API endpoint (via %s): %s\n", config.EnvVarEndpoint, endpoint) - case config.SourceFile: - fmt.Fprintf(out, "Fastly API endpoint (via config file): %s\n", endpoint) - default: - fmt.Fprintf(out, "Fastly API endpoint: %s\n", endpoint) - } +func displayAPIEndpoint(endpoint string, endpointSource lookup.Source, out io.Writer) { + switch endpointSource { + case lookup.SourceFlag: + fmt.Fprintf(out, "Fastly API endpoint (via --api): %s\n", endpoint) + case lookup.SourceEnvironment: + fmt.Fprintf(out, "Fastly API endpoint (via %s): %s\n", env.APIEndpoint, endpoint) + case lookup.SourceFile: + fmt.Fprintf(out, "Fastly API endpoint (via config file): %s\n", endpoint) + case lookup.SourceDefault, lookup.SourceUndefined: + fallthrough + default: + fmt.Fprintf(out, "Fastly API endpoint: %s\n", endpoint) } +} - globals.Client, err = cf(token, endpoint) +func configureClients(token, apiEndpoint string, acf global.APIClientFactory, debugMode bool) (apiClient api.Interface, rtsClient api.RealtimeStatsInterface, err error) { + apiClient, err = acf(token, apiEndpoint, debugMode) if err != nil { - return fmt.Errorf("error constructing Fastly API client: %w", err) + return nil, nil, fmt.Errorf("error constructing Fastly API client: %w", err) } - globals.RTSClient, err = fastly.NewRealtimeStatsClientForEndpoint(token, fastly.DefaultRealtimeStatsEndpoint) + rtsClient, err = fastly.NewRealtimeStatsClientForEndpoint(token, fastly.DefaultRealtimeStatsEndpoint) if err != nil { - return fmt.Errorf("error constructing Fastly realtime stats client: %w", err) + return nil, nil, fmt.Errorf("error constructing Fastly realtime stats client: %w", err) } - command, found := common.SelectCommand(name, commands) - if !found { - usage := Usage(args, app, out, io.Discard) - return errors.RemediationError{Prefix: usage, Inner: fmt.Errorf("command not found")} + return apiClient, rtsClient, nil +} + +func checkForUpdates(av github.AssetVersioner, commandName string, quietMode bool) func(io.Writer) { + if av != nil && commandName != "update" && !version.IsPreRelease(revision.AppVersion) { + return update.CheckAsync(revision.AppVersion, av, quietMode) } + return func(_ io.Writer) { + // no-op + } +} - if cliVersioner != nil && name != "update" && !version.IsPreRelease(revision.AppVersion) { - ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) - defer cancel() // push cancel on the defer stack first... - f := update.CheckAsync(ctx, file, configFilePath, revision.AppVersion, cliVersioner) - defer f(out) // ...and the printing function second, so we hit the timeout +// determineProfile determines if the provided token was acquired via the +// fastly.toml manifest, the --profile flag, or was a default profile from +// within the config.toml application configuration. +func determineProfile(manifestValue, flagValue string, profiles config.Profiles) string { + if manifestValue != "" { + return manifestValue + " -- via fastly.toml" + } + if flagValue != "" { + return flagValue } + name, _ := profile.Default(profiles) + return name +} - return command.Exec(in, out) +// commandCollectsData determines if the command to be executed is one that +// collects data related to a Wasm binary. +func commandCollectsData(command string) bool { + switch command { + case "compute build", "compute hashsum", "compute hash-files", "compute publish", "compute serve": + return true + } + return false } -// APIClientFactory creates a Fastly API client (modeled as an api.Interface) -// from a user-provided API token. It exists as a type in order to parameterize -// the Run helper with it: in the real CLI, we can use NewClient from the Fastly -// API client library via RealClient; in tests, we can provide a mock API -// interface via MockClient. -type APIClientFactory func(token, endpoint string) (api.Interface, error) - -// FastlyAPIClient is a ClientFactory that returns a real Fastly API client -// using the provided token and endpoint. -func FastlyAPIClient(token, endpoint string) (api.Interface, error) { - client, err := fastly.NewClientForEndpoint(token, endpoint) - return client, err +// commandRequiresAuthServer determines if the command to be executed is one that +// requires just the authentication server to be running. +func commandRequiresAuthServer(command string) bool { + switch command { + case "profile create", "profile switch", "profile update", "sso": + return true + } + return false } -// contextHasHelpFlag asserts whether a given kingpin.ParseContext contains a -// `help` flag. -func contextHasHelpFlag(ctx *kingpin.ParseContext) bool { - _, ok := ctx.Elements.FlagMap()["help"] - return ok +// commandRequiresToken determines if the command to be executed is one that +// requires an API token. +func commandRequiresToken(command argparser.Command) bool { + commandName := command.Name() + switch commandName { + case "compute init": + if initCmd, ok := command.(*compute.InitCommand); ok { + return text.IsFastlyID(initCmd.CloneFrom) + } + return false + case "compute build", "compute hash-files", "compute metadata", "compute serve": + return false + } + commandName = strings.Split(commandName, " ")[0] + switch commandName { + case "config", "profile", "sso", "update", "version": + return false + } + return true } -// argsIsHelpJSON determines whether the supplied command arguments are exactly -// `help --format json`. -func argsIsHelpJSON(args []string) bool { - return (len(args) == 3 && - args[0] == "help" && - args[1] == "--format" && - args[2] == "json") +// configureAuth processes authentication tasks. +// +// 1. Acquire .well-known configuration data. +// 2. Instantiate authentication server. +// 3. Start up request multiplexer. +func configureAuth(apiEndpoint string, args []string, f config.File, c api.HTTPClient, e config.Environment) (*auth.Server, error) { + metadataEndpoint := fmt.Sprintf(auth.OIDCMetadata, accountEndpoint(args, e, f)) + req, err := http.NewRequest(http.MethodGet, metadataEndpoint, nil) + if err != nil { + return nil, fmt.Errorf("failed to construct request object for OpenID Connect .well-known metadata: %w", err) + } + + resp, err := c.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to request OpenID Connect .well-known metadata (%s): %w", metadataEndpoint, err) + } + // Set a more meaningful error message when Fastly servers are unresponsive + // check if the response code is a 500 or above + if resp.StatusCode >= http.StatusInternalServerError { + var body []byte + body, _ = io.ReadAll(resp.Body) // default to empty string if we fail to read the body + return nil, fmt.Errorf("the Fastly servers are unresponsive, please check the Fastly Status page (https://fastlystatus.com) and reach out to support if the error persists (HTTP Status Code: %d, Error Message: %s)", resp.StatusCode, body) + } + + openIDConfig, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read OpenID Connect .well-known metadata: %w", err) + } + _ = resp.Body.Close() + + var wellknown auth.WellKnownEndpoints + err = json.Unmarshal(openIDConfig, &wellknown) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal OpenID Connect .well-known metadata: %w", err) + } + + result := make(chan auth.AuthorizationResult) + router := http.NewServeMux() + verifier, err := oidc.NewCodeVerifier() + if err != nil { + return nil, fsterr.RemediationError{ + Inner: fmt.Errorf("failed to generate a code verifier for SSO authentication server: %w", err), + Remediation: auth.Remediation, + } + } + + authServer := &auth.Server{ + APIEndpoint: apiEndpoint, + DebugMode: e.DebugMode, + HTTPClient: c, + Result: result, + Router: router, + Verifier: verifier, + WellKnownEndpoints: wellknown, + } + + router.HandleFunc("/callback", authServer.HandleCallback()) + + return authServer, nil } -// isCompletion determines whether the supplied command arguments are for -// bash/zsh completion output. -func isCompletion(args []string) bool { - var found bool - for _, arg := range args { - if completionRegExp.MatchString(arg) { - found = true +// accountEndpoint parses the account endpoint from multiple locations. +func accountEndpoint(args []string, e config.Environment, cfg config.File) string { + // Check for flag override. + for i, a := range args { + if a == "--account" && i+1 < len(args) { + return args[i+1] } } - return found + // Check for environment override. + if e.AccountEndpoint != "" { + return e.AccountEndpoint + } + // Check for internal config override. + if cfg.Fastly.AccountEndpoint != global.DefaultAccountEndpoint && cfg.Fastly.AccountEndpoint != "" { + return cfg.Fastly.AccountEndpoint + } + // Otherwise return the default account endpoint. + return global.DefaultAccountEndpoint } diff --git a/pkg/app/run_test.go b/pkg/app/run_test.go index 6b1d1a92a..41c54bf55 100644 --- a/pkg/app/run_test.go +++ b/pkg/app/run_test.go @@ -4,74 +4,144 @@ import ( "bufio" "bytes" "io" + "os" "strings" "testing" - "github.com/fastly/cli/pkg/api" "github.com/fastly/cli/pkg/app" - "github.com/fastly/cli/pkg/config" "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/global" "github.com/fastly/cli/pkg/testutil" - "github.com/fastly/cli/pkg/update" ) -func TestApplication(t *testing.T) { - // These tests should only verify the app.Run helper wires things up - // correctly, and check behaviors that can't be associated with a specific - // command or subcommand. Commands should be tested in their packages, - // leveraging the app.Run helper as appropriate. - for _, testcase := range []struct { - name string - args []string - wantOut string - wantErr string - }{ +// If you add a Short flag and this test starts failing, it could be due to the same short flag existing at the global level. +func TestShellCompletion(t *testing.T) { + scenarios := []testutil.CLIScenario{ { - name: "no args", - args: nil, - wantErr: helpDefault + "\nERROR: error parsing arguments: command not specified.\n", - }, - { - name: "help flag only", - args: []string{"--help"}, - wantErr: helpDefault + "\nERROR: error parsing arguments: command not specified.\n", + Name: "bash shell complete", + Args: "--completion-script-bash", + WantOutput: ` +_fastly_bash_autocomplete() { + local cur prev opts base + COMPREPLY=() + cur="${COMP_WORDS[COMP_CWORD]}" + opts=$( ${COMP_WORDS[0]} --completion-bash ${COMP_WORDS[@]:1:$COMP_CWORD} ) + COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) ) + return 0 +} +complete -F _fastly_bash_autocomplete fastly + +`, }, { - name: "help argument only", - args: []string{"help"}, - wantErr: fullFatHelpDefault, + Name: "zsh shell complete", + Args: "--completion-script-zsh", + WantOutput: ` +#compdef fastly +autoload -U compinit && compinit +autoload -U bashcompinit && bashcompinit + +_fastly_bash_autocomplete() { + local cur prev opts base + COMPREPLY=() + cur="${COMP_WORDS[COMP_CWORD]}" + opts=$( ${COMP_WORDS[0]} --completion-bash ${COMP_WORDS[@]:1:$COMP_CWORD} ) + COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) ) + [[ $COMPREPLY ]] && return + compgen -f + return 0 +} +complete -F _fastly_bash_autocomplete fastly +`, }, + // FIXME: Put back `sso` GA. { - name: "help service", - args: []string{"help", "service"}, - wantErr: helpService, + Name: "shell evaluate completion options", + Args: "--completion-bash", + WantOutput: `help +sso +acl +acl-entry +alerts +auth-token +backend +compute +config +config-store +config-store-entry +dashboard +dictionary +dictionary-entry +domain +domain-v1 +healthcheck +install +ip-list +kv-store +kv-store-entry +log-tail +logging +object-storage +pops +products +profile +purge +rate-limit +resource-link +secret-store +secret-store-entry +service +service-auth +service-version +stats +tls-config +tls-custom +tls-platform +tls-subscription +update +user +vcl +version +whoami +`, }, - } { - t.Run(testcase.name, func(t *testing.T) { + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(testcase.Name, func(t *testing.T) { var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - configFilePath = "/dev/null" - clientFactory = mock.APIClient(mock.API{}) - httpClient api.HTTPClient = nil - cliVersioner update.Versioner = nil - stdin io.Reader = nil - stdout bytes.Buffer - stderr bytes.Buffer + stdout bytes.Buffer + stderr bytes.Buffer ) - err := app.Run(args, env, file, configFilePath, clientFactory, httpClient, cliVersioner, stdin, &stdout) + + // NOTE: The Kingpin dependency internally overrides our stdout + // variable when doing shell completion to the os.Stdout variable and so + // in order for us to verify it contains the shell completion output, we + // need an os.Pipe so we can copy off anything written to os.Stdout. + old := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + outC := make(chan string) + + go func() { + var buf bytes.Buffer + _, _ = io.Copy(&buf, r) + outC <- buf.String() + }() + + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + return testutil.MockGlobalData(testutil.SplitArgs(testcase.Args), &stdout), nil + } + err := app.Run(testutil.SplitArgs(testcase.Args), nil) if err != nil { errors.Deduce(err).Print(&stderr) } - // Our flag package creates trailing space on - // some lines. Strip what we get so we don't - // need to maintain invisible spaces in - // wantOut/wantErr below. - testutil.AssertString(t, testcase.wantOut, stripTrailingSpace(stdout.String())) - testutil.AssertString(t, testcase.wantErr, stripTrailingSpace(stderr.String())) + w.Close() + os.Stdout = old + out := <-outC + + testutil.AssertString(t, testcase.WantOutput, stripTrailingSpace(out)) }) } } @@ -82,3159 +152,8 @@ func stripTrailingSpace(str string) string { scan := bufio.NewScanner(strings.NewReader(str)) for scan.Scan() { - buf.WriteString(strings.TrimRight(scan.Text(), " \t\r\n")) - buf.WriteString("\n") + _, _ = buf.WriteString(strings.TrimRight(scan.Text(), " \t\r\n")) + _, _ = buf.WriteString("\n") } return buf.String() } - -var helpDefault = strings.TrimSpace(` -USAGE - fastly [] [ ...] - -A tool to interact with the Fastly API - -GLOBAL FLAGS - --help Show context-sensitive help. - -t, --token=TOKEN Fastly API token (or via FASTLY_API_TOKEN) - -v, --verbose Verbose logging - -COMMANDS - help Show help. - configure Configure the Fastly CLI - whoami Get information about the currently authenticated account - version Display version information for the Fastly CLI - update Update the CLI to the latest version - service Manipulate Fastly services - service-version Manipulate Fastly service versions - compute Manage Compute@Edge packages - domain Manipulate Fastly service version domains - backend Manipulate Fastly service version backends - healthcheck Manipulate Fastly service version healthchecks - dictionary Manipulate Fastly edge dictionaries - dictionaryitem Manipulate Fastly edge dictionary items - logging Manipulate Fastly service version logging endpoints - logs Compute@Edge Log Tailing - stats View statistics (historical and realtime) for a Fastly - service -`) + "\n\n" - -var helpService = strings.TrimSpace(` -USAGE - fastly [] service - -GLOBAL FLAGS - --help Show context-sensitive help. - -t, --token=TOKEN Fastly API token (or via FASTLY_API_TOKEN) - -v, --verbose Verbose logging - -SUBCOMMANDS - - service create --name=NAME [] - Create a Fastly service - - -n, --name=NAME Service name - --type=wasm Service type. Can be one of "wasm" or "vcl", defaults - to "wasm". - --comment=COMMENT Human-readable comment - - service list - List Fastly services - - - service describe [] - Show detailed information about a Fastly service - - -s, --service-id=SERVICE-ID Service ID - - service update [] - Update a Fastly service - - -s, --service-id=SERVICE-ID Service ID - -n, --name=NAME Service name - --comment=COMMENT Human-readable comment - - service delete [] - Delete a Fastly service - - -s, --service-id=SERVICE-ID Service ID - -f, --force Force deletion of an active service - - service search [] - Search for a Fastly service by name - - -n, --name=NAME Service name - -`) + "\n\n" - -var fullFatHelpDefault = strings.TrimSpace(` -USAGE - fastly [] - -A tool to interact with the Fastly API - -GLOBAL FLAGS - --help Show context-sensitive help. - -t, --token=TOKEN Fastly API token (or via FASTLY_API_TOKEN) - -v, --verbose Verbose logging - -COMMANDS - help [ ...] - Show help. - - - configure - Configure the Fastly CLI - - - whoami - Get information about the currently authenticated account - - - version - Display version information for the Fastly CLI - - - update - Update the CLI to the latest version - - - service create --name=NAME [] - Create a Fastly service - - -n, --name=NAME Service name - --type=wasm Service type. Can be one of "wasm" or "vcl", defaults - to "wasm". - --comment=COMMENT Human-readable comment - - service list - List Fastly services - - - service describe [] - Show detailed information about a Fastly service - - -s, --service-id=SERVICE-ID Service ID - - service update [] - Update a Fastly service - - -s, --service-id=SERVICE-ID Service ID - -n, --name=NAME Service name - --comment=COMMENT Human-readable comment - - service delete [] - Delete a Fastly service - - -s, --service-id=SERVICE-ID Service ID - -f, --force Force deletion of an active service - - service search [] - Search for a Fastly service by name - - -n, --name=NAME Service name - - service-version clone --version=VERSION [] - Clone a Fastly service version - - -s, --service-id=SERVICE-ID Service ID - --version=VERSION Number of version you wish to clone - - service-version list [] - List Fastly service versions - - -s, --service-id=SERVICE-ID Service ID - - service-version update --version=VERSION [] - Update a Fastly service version - - -s, --service-id=SERVICE-ID Service ID - --version=VERSION Number of version you wish to update - --comment=COMMENT Human-readable comment - - service-version activate --version=VERSION [] - Activate a Fastly service version - - -s, --service-id=SERVICE-ID Service ID - --version=VERSION Number of version you wish to activate - - service-version deactivate --version=VERSION [] - Deactivate a Fastly service version - - -s, --service-id=SERVICE-ID Service ID - --version=VERSION Number of version you wish to deactivate - - service-version lock --version=VERSION [] - Lock a Fastly service version - - -s, --service-id=SERVICE-ID Service ID - --version=VERSION Number of version you wish to lock - - compute init [] - Initialize a new Compute@Edge package locally - - -n, --name=NAME Name of package, defaulting to directory name - of the --path destination - -d, --description=DESCRIPTION Description of the package - -a, --author=AUTHOR ... Author(s) of the package - -l, --language=LANGUAGE Language of the package - -f, --from=FROM Git repository containing package template - -p, --path=PATH Destination to write the new package, - defaulting to the current directory - --force Skip non-empty directory verification step - and force new project creation - - compute build [] - Build a Compute@Edge package locally - - --name=NAME Package name - --language=LANGUAGE Language type - --include-source Include source code in built package - --force Skip verification steps and force build - - compute deploy [] - Deploy a package to a Fastly Compute@Edge service - - -s, --service-id=SERVICE-ID Service ID - --version=VERSION Number of version to activate - -p, --path=PATH Path to package - --domain=DOMAIN The name of the domain associated to the - package - --backend=BACKEND A hostname, IPv4, or IPv6 address for the - package backend - --backend-port=BACKEND-PORT - A port number for the package backend - - compute publish [] - Build and deploy a Compute@Edge package to a Fastly service - - --name=NAME Package name - --language=LANGUAGE Language type - --include-source Include source code in built package - --force Skip verification steps and force build - -s, --service-id=SERVICE-ID Service ID - --version=VERSION Number of version to activate - -p, --path=PATH Path to package - --domain=DOMAIN The name of the domain associated to the - package - --backend=BACKEND A hostname, IPv4, or IPv6 address for the - package backend - --backend-port=BACKEND-PORT - A port number for the package backend - - compute update --service-id=SERVICE-ID --version=VERSION --path=PATH - Update a package on a Fastly Compute@Edge service version - - -s, --service-id=SERVICE-ID Service ID - --version=VERSION Number of service version - -p, --path=PATH Path to package - - compute validate --path=PATH - Validate a Compute@Edge package - - -p, --path=PATH Path to package - - domain create --name=NAME --version=VERSION [] - Create a domain on a Fastly service version - - -n, --name=NAME Domain name - --comment=COMMENT A descriptive note - -s, --service-id=SERVICE-ID Service ID - --version=VERSION Number of service version - - domain list --version=VERSION [] - List domains on a Fastly service version - - -s, --service-id=SERVICE-ID Service ID - --version=VERSION Number of service version - - domain describe --version=VERSION --name=NAME [] - Show detailed information about a domain on a Fastly service version - - -s, --service-id=SERVICE-ID Service ID - --version=VERSION Number of service version - -n, --name=NAME Name of domain - - domain update --version=VERSION --name=NAME [] - Update a domain on a Fastly service version - - -s, --service-id=SERVICE-ID Service ID - --version=VERSION Number of service version - -n, --name=NAME Domain name - --new-name=NEW-NAME New domain name - --comment=COMMENT A descriptive note - - domain delete --name=NAME --version=VERSION [] - Delete a domain on a Fastly service version - - -n, --name=NAME Domain name - -s, --service-id=SERVICE-ID Service ID - --version=VERSION Number of service version - - backend create --service-id=SERVICE-ID --version=VERSION --name=NAME --address=ADDRESS [] - Create a backend on a Fastly service version - - -s, --service-id=SERVICE-ID Service ID - --version=VERSION Number of service version - -n, --name=NAME Backend name - --address=ADDRESS A hostname, IPv4, or IPv6 address for the - backend - --comment=COMMENT A descriptive note - --port=PORT Port number of the address - --override-host=OVERRIDE-HOST - The hostname to override the Host header - --connect-timeout=CONNECT-TIMEOUT - How long to wait for a timeout in - milliseconds - --max-conn=MAX-CONN Maximum number of connections - --first-byte-timeout=FIRST-BYTE-TIMEOUT - How long to wait for the first bytes in - milliseconds - --between-bytes-timeout=BETWEEN-BYTES-TIMEOUT - How long to wait between bytes in - milliseconds - --auto-loadbalance Whether or not this backend should be - automatically load balanced - --weight=WEIGHT Weight used to load balance this backend - against others - --request-condition=REQUEST-CONDITION - Condition, which if met, will select this - backend during a request - --healthcheck=HEALTHCHECK The name of the healthcheck to use with this - backend - --shield=SHIELD The shield POP designated to reduce inbound - load on this origin by serving the cached - data to the rest of the network - --use-ssl Whether or not to use SSL to reach the - backend - --ssl-check-cert Be strict on checking SSL certs - --ssl-ca-cert=SSL-CA-CERT CA certificate attached to origin - --ssl-client-cert=SSL-CLIENT-CERT - Client certificate attached to origin - --ssl-client-key=SSL-CLIENT-KEY - Client key attached to origin - --ssl-cert-hostname=SSL-CERT-HOSTNAME - Overrides ssl_hostname, but only for cert - verification. Does not affect SNI at all. - --ssl-sni-hostname=SSL-SNI-HOSTNAME - Overrides ssl_hostname, but only for SNI in - the handshake. Does not affect cert - validation at all. - --min-tls-version=MIN-TLS-VERSION - Minimum allowed TLS version on SSL - connections to this backend - --max-tls-version=MAX-TLS-VERSION - Maximum allowed TLS version on SSL - connections to this backend - --ssl-ciphers=SSL-CIPHERS ... - List of OpenSSL ciphers (see - https://www.openssl.org/docs/man1.0.2/man1/ciphers - for details) - - backend list --service-id=SERVICE-ID --version=VERSION - List backends on a Fastly service version - - -s, --service-id=SERVICE-ID Service ID - --version=VERSION Number of service version - - backend describe --service-id=SERVICE-ID --version=VERSION --name=NAME - Show detailed information about a backend on a Fastly service version - - -s, --service-id=SERVICE-ID Service ID - --version=VERSION Number of service version - -n, --name=NAME Name of backend - - backend update --service-id=SERVICE-ID --version=VERSION --name=NAME [] - Update a backend on a Fastly service version - - -s, --service-id=SERVICE-ID Service ID - --version=VERSION Number of service version - -n, --name=NAME backend name - --new-name=NEW-NAME New backend name - --comment=COMMENT A descriptive note - --address=ADDRESS A hostname, IPv4, or IPv6 address for the - backend - --port=PORT Port number of the address - --override-host=OVERRIDE-HOST - The hostname to override the Host header - --connect-timeout=CONNECT-TIMEOUT - How long to wait for a timeout in - milliseconds - --max-conn=MAX-CONN Maximum number of connections - --first-byte-timeout=FIRST-BYTE-TIMEOUT - How long to wait for the first bytes in - milliseconds - --between-bytes-timeout=BETWEEN-BYTES-TIMEOUT - How long to wait between bytes in - milliseconds - --auto-loadbalance Whether or not this backend should be - automatically load balanced - --weight=WEIGHT Weight used to load balance this backend - against others - --request-condition=REQUEST-CONDITION - condition, which if met, will select this - backend during a request - --healthcheck=HEALTHCHECK The name of the healthcheck to use with this - backend - --shield=SHIELD The shield POP designated to reduce inbound - load on this origin by serving the cached - data to the rest of the network - --use-ssl Whether or not to use SSL to reach the - backend - --ssl-check-cert Be strict on checking SSL certs - --ssl-ca-cert=SSL-CA-CERT CA certificate attached to origin - --ssl-client-cert=SSL-CLIENT-CERT - Client certificate attached to origin - --ssl-client-key=SSL-CLIENT-KEY - Client key attached to origin - --ssl-cert-hostname=SSL-CERT-HOSTNAME - Overrides ssl_hostname, but only for cert - verification. Does not affect SNI at all. - --ssl-sni-hostname=SSL-SNI-HOSTNAME - Overrides ssl_hostname, but only for SNI in - the handshake. Does not affect cert - validation at all. - --min-tls-version=MIN-TLS-VERSION - Minimum allowed TLS version on SSL - connections to this backend - --max-tls-version=MAX-TLS-VERSION - Maximum allowed TLS version on SSL - connections to this backend - --ssl-ciphers=SSL-CIPHERS ... - List of OpenSSL ciphers (see - https://www.openssl.org/docs/man1.0.2/man1/ciphers - for details) - - backend delete --service-id=SERVICE-ID --version=VERSION --name=NAME - Delete a backend on a Fastly service version - - -s, --service-id=SERVICE-ID Service ID - --version=VERSION Number of service version - -n, --name=NAME Backend name - - healthcheck create --version=VERSION --name=NAME [] - Create a healthcheck on a Fastly service version - - -s, --service-id=SERVICE-ID Service ID - --version=VERSION Number of service version - -n, --name=NAME Healthcheck name - --comment=COMMENT A descriptive note - --method=METHOD Which HTTP method to use - --host=HOST Which host to check - --path=PATH The path to check - --http-version=HTTP-VERSION - Whether to use version 1.0 or 1.1 HTTP - --timeout=TIMEOUT Timeout in milliseconds - --check-interval=CHECK-INTERVAL - How often to run the healthcheck in - milliseconds - --expected-response=EXPECTED-RESPONSE - The status code expected from the host - --window=WINDOW The number of most recent healthcheck queries - to keep for this healthcheck - --threshold=THRESHOLD How many healthchecks must succeed to be - considered healthy - --initial=INITIAL When loading a config, the initial number of - probes to be seen as OK - - healthcheck list --version=VERSION [] - List healthchecks on a Fastly service version - - -s, --service-id=SERVICE-ID Service ID - --version=VERSION Number of service version - - healthcheck describe --version=VERSION --name=NAME [] - Show detailed information about a healthcheck on a Fastly service version - - -s, --service-id=SERVICE-ID Service ID - --version=VERSION Number of service version - -n, --name=NAME Name of healthcheck - - healthcheck update --version=VERSION --name=NAME [] - Update a healthcheck on a Fastly service version - - -s, --service-id=SERVICE-ID Service ID - --version=VERSION Number of service version - -n, --name=NAME Healthcheck name - --new-name=NEW-NAME Healthcheck name - --comment=COMMENT A descriptive note - --method=METHOD Which HTTP method to use - --host=HOST Which host to check - --path=PATH The path to check - --http-version=HTTP-VERSION - Whether to use version 1.0 or 1.1 HTTP - --timeout=TIMEOUT Timeout in milliseconds - --check-interval=CHECK-INTERVAL - How often to run the healthcheck in - milliseconds - --expected-response=EXPECTED-RESPONSE - The status code expected from the host - --window=WINDOW The number of most recent healthcheck queries - to keep for this healthcheck - --threshold=THRESHOLD How many healthchecks must succeed to be - considered healthy - --initial=INITIAL When loading a config, the initial number of - probes to be seen as OK - - healthcheck delete --version=VERSION --name=NAME [] - Delete a healthcheck on a Fastly service version - - -s, --service-id=SERVICE-ID Service ID - --version=VERSION Number of service version - -n, --name=NAME Healthcheck name - - dictionary create --version=VERSION --name=NAME [] - Create a Fastly edge dictionary on a Fastly service version - - -s, --service-id=SERVICE-ID Service ID - --version=VERSION Number of service version - -n, --name=NAME Name of Dictionary - --write-only=WRITE-ONLY Whether to mark this dictionary as write-only. - Can be true or false (defaults to false) - - dictionary describe --version=VERSION --name=NAME [] - Show detailed information about a Fastly edge dictionary - - -s, --service-id=SERVICE-ID Service ID - --version=VERSION Number of service version - -n, --name=NAME Name of Dictionary - - dictionary delete --version=VERSION --name=NAME [] - Delete a Fastly edge dictionary from a Fastly service version - - -s, --service-id=SERVICE-ID Service ID - --version=VERSION Number of service version - -n, --name=NAME Name of Dictionary - - dictionary list --version=VERSION [] - List all dictionaries on a Fastly service version - - -s, --service-id=SERVICE-ID Service ID - --version=VERSION Number of service version - - dictionary update --version=VERSION --name=NAME [] - Update name of dictionary on a Fastly service version - - -s, --service-id=SERVICE-ID Service ID - --version=VERSION Number of service version - -n, --name=NAME Old name of Dictionary - --new-name=NEW-NAME New name of Dictionary - --write-only=WRITE-ONLY Whether to mark this dictionary as write-only. - Can be true or false (defaults to false) - - dictionaryitem list --dictionary-id=DICTIONARY-ID [] - List items in a Fastly edge dictionary - - -s, --service-id=SERVICE-ID Service ID - --dictionary-id=DICTIONARY-ID - Dictionary ID - - dictionaryitem describe --dictionary-id=DICTIONARY-ID --key=KEY [] - Show detailed information about a Fastly edge dictionary item - - -s, --service-id=SERVICE-ID Service ID - --dictionary-id=DICTIONARY-ID - Dictionary ID - --key=KEY Dictionary item key - - dictionaryitem create --dictionary-id=DICTIONARY-ID --key=KEY --value=VALUE [] - Create a new item on a Fastly edge dictionary - - -s, --service-id=SERVICE-ID Service ID - --dictionary-id=DICTIONARY-ID - Dictionary ID - --key=KEY Dictionary item key - --value=VALUE Dictionary item value - - dictionaryitem update --dictionary-id=DICTIONARY-ID --key=KEY --value=VALUE [] - Update or insert an item on a Fastly edge dictionary - - -s, --service-id=SERVICE-ID Service ID - --dictionary-id=DICTIONARY-ID - Dictionary ID - --key=KEY Dictionary item key - --value=VALUE Dictionary item value - - dictionaryitem delete --dictionary-id=DICTIONARY-ID --key=KEY [] - Delete an item from a Fastly edge dictionary - - -s, --service-id=SERVICE-ID Service ID - --dictionary-id=DICTIONARY-ID - Dictionary ID - --key=KEY Dictionary item key - - dictionaryitem batchmodify --dictionary-id=DICTIONARY-ID --file=FILE [] - Update multiple items in a Fastly edge dictionary - - -s, --service-id=SERVICE-ID Service ID - --dictionary-id=DICTIONARY-ID - Dictionary ID - --file=FILE Batch update json file - - logging bigquery create --name=NAME --version=VERSION --project-id=PROJECT-ID --dataset=DATASET --table=TABLE --user=USER --secret-key=SECRET-KEY [] - Create a BigQuery logging endpoint on a Fastly service version - - -n, --name=NAME The name of the BigQuery logging object. Used - as a primary key for API access - --version=VERSION Number of service version - --project-id=PROJECT-ID Your Google Cloud Platform project ID - --dataset=DATASET Your BigQuery dataset - --table=TABLE Your BigQuery table - --user=USER Your Google Cloud Platform service account - email address. The client_email field in your - service account authentication JSON. - --secret-key=SECRET-KEY Your Google Cloud Platform account secret key. - The private_key field in your service account - authentication JSON. - -s, --service-id=SERVICE-ID Service ID - --template-suffix=TEMPLATE-SUFFIX - BigQuery table name suffix template - --format=FORMAT Apache style log formatting. Must produce JSON - that matches the schema of your BigQuery table - --format-version=FORMAT-VERSION - The version of the custom logging format used - for the configured endpoint. Can be either 2 - (the default, version 2 log format) or 1 (the - version 1 log format). The logging call gets - placed by default in vcl_log if format_version - is set to 2 and in vcl_deliver if - format_version is set to 1 - --placement=PLACEMENT Where in the generated VCL the logging call - should be placed, overriding any format_version - default. Can be none or waf_debug. This field - is not required and has no default value - --response-condition=RESPONSE-CONDITION - The name of an existing condition in the - configured endpoint, or leave blank to always - execute - - logging bigquery list --version=VERSION [] - List BigQuery endpoints on a Fastly service version - - -s, --service-id=SERVICE-ID Service ID - --version=VERSION Number of service version - - logging bigquery describe --version=VERSION --name=NAME [] - Show detailed information about a BigQuery logging endpoint on a Fastly - service version - - -s, --service-id=SERVICE-ID Service ID - --version=VERSION Number of service version - -n, --name=NAME The name of the BigQuery logging object - - logging bigquery update --version=VERSION --name=NAME [] - Update a BigQuery logging endpoint on a Fastly service version - - --version=VERSION Number of service version - -n, --name=NAME The name of the BigQuery logging object - -s, --service-id=SERVICE-ID Service ID - --new-name=NEW-NAME New name of the BigQuery logging object - --project-id=PROJECT-ID Your Google Cloud Platform project ID - --dataset=DATASET Your BigQuery dataset - --table=TABLE Your BigQuery table - --user=USER Your Google Cloud Platform service account - email address. The client_email field in your - service account authentication JSON. - --secret-key=SECRET-KEY Your Google Cloud Platform account secret key. - The private_key field in your service account - authentication JSON. - --template-suffix=TEMPLATE-SUFFIX - BigQuery table name suffix template - --format=FORMAT Apache style log formatting. Must produce JSON - that matches the schema of your BigQuery table - --format-version=FORMAT-VERSION - The version of the custom logging format used - for the configured endpoint. Can be either 2 - (the default, version 2 log format) or 1 (the - version 1 log format). The logging call gets - placed by default in vcl_log if format_version - is set to 2 and in vcl_deliver if - format_version is set to 1 - --placement=PLACEMENT Where in the generated VCL the logging call - should be placed, overriding any format_version - default. Can be none or waf_debug. This field - is not required and has no default value - --response-condition=RESPONSE-CONDITION - The name of an existing condition in the - configured endpoint, or leave blank to always - execute - - logging bigquery delete --version=VERSION --name=NAME [] - Delete a BigQuery logging endpoint on a Fastly service version - - --version=VERSION Number of service version - -n, --name=NAME The name of the BigQuery logging object - -s, --service-id=SERVICE-ID Service ID - - logging s3 create --name=NAME --version=VERSION --bucket=BUCKET [] - Create an Amazon S3 logging endpoint on a Fastly service version - - -n, --name=NAME The name of the S3 logging object. Used as a - primary key for API access - --version=VERSION Number of service version - --bucket=BUCKET Your S3 bucket name - --access-key=ACCESS-KEY Your S3 account access key - --secret-key=SECRET-KEY Your S3 account secret key - --iam-role=IAM-ROLE The IAM role ARN for logging - -s, --service-id=SERVICE-ID Service ID - --domain=DOMAIN The domain of the S3 endpoint - --path=PATH The path to upload logs to - --period=PERIOD How frequently log files are finalized so they - can be available for reading (in seconds, - default 3600) - --gzip-level=GZIP-LEVEL What level of GZIP encoding to have when - dumping logs (default 0, no compression) - --format=FORMAT Apache style log formatting - --format-version=FORMAT-VERSION - The version of the custom logging format used - for the configured endpoint. Can be either 2 - (default) or 1 - --message-type=MESSAGE-TYPE - How the message should be formatted. One of: - classic (default), loggly, logplex or blank - --response-condition=RESPONSE-CONDITION - The name of an existing condition in the - configured endpoint, or leave blank to always - execute - --timestamp-format=TIMESTAMP-FORMAT - strftime specified timestamp formatting - (default "%Y-%m-%dT%H:%M:%S.000") - --redundancy=REDUNDANCY The S3 redundancy level. Can be either standard - or reduced_redundancy - --placement=PLACEMENT Where in the generated VCL the logging call - should be placed, overriding any format_version - default. Can be none or waf_debug - --public-key=PUBLIC-KEY A PGP public key that Fastly will use to - encrypt your log files before writing them to - disk - --server-side-encryption=SERVER-SIDE-ENCRYPTION - Set to enable S3 Server Side Encryption. Can be - either AES256 or aws:kms - --server-side-encryption-kms-key-id=SERVER-SIDE-ENCRYPTION-KMS-KEY-ID - Server-side KMS Key ID. Must be set if - server-side-encryption is set to aws:kms - --compression-codec=COMPRESSION-CODEC - The codec used for compression of your logs. - Valid values are zstd, snappy, and gzip. If the - specified codec is "gzip", gzip_level will - default to 3. To specify a different level, - leave compression_codec blank and explicitly - set the level using gzip_level. Specifying both - compression_codec and gzip_level in the same - API request will result in an error. - - logging s3 list --version=VERSION [] - List S3 endpoints on a Fastly service version - - -s, --service-id=SERVICE-ID Service ID - --version=VERSION Number of service version - - logging s3 describe --version=VERSION --name=NAME [] - Show detailed information about a S3 logging endpoint on a Fastly service - version - - -s, --service-id=SERVICE-ID Service ID - --version=VERSION Number of service version - -n, --name=NAME The name of the S3 logging object - - logging s3 update --version=VERSION --name=NAME [] - Update a S3 logging endpoint on a Fastly service version - - --version=VERSION Number of service version - -n, --name=NAME The name of the S3 logging object - -s, --service-id=SERVICE-ID Service ID - --new-name=NEW-NAME New name of the S3 logging object - --bucket=BUCKET Your S3 bucket name - --access-key=ACCESS-KEY Your S3 account access key - --secret-key=SECRET-KEY Your S3 account secret key - --iam-role=IAM-ROLE The IAM role ARN for logging - --domain=DOMAIN The domain of the S3 endpoint - --path=PATH The path to upload logs to - --period=PERIOD How frequently log files are finalized so they - can be available for reading (in seconds, - default 3600) - --gzip-level=GZIP-LEVEL What level of GZIP encoding to have when - dumping logs (default 0, no compression) - --format=FORMAT Apache style log formatting - --format-version=FORMAT-VERSION - The version of the custom logging format used - for the configured endpoint. Can be either 2 - (default) or 1 - --message-type=MESSAGE-TYPE - How the message should be formatted. One of: - classic (default), loggly, logplex or blank - --response-condition=RESPONSE-CONDITION - The name of an existing condition in the - configured endpoint, or leave blank to always - execute - --timestamp-format=TIMESTAMP-FORMAT - strftime specified timestamp formatting - (default "%Y-%m-%dT%H:%M:%S.000") - --redundancy=REDUNDANCY The S3 redundancy level. Can be either standard - or reduced_redundancy - --placement=PLACEMENT Where in the generated VCL the logging call - should be placed, overriding any format_version - default. Can be none or waf_debug - --public-key=PUBLIC-KEY A PGP public key that Fastly will use to - encrypt your log files before writing them to - disk - --server-side-encryption=SERVER-SIDE-ENCRYPTION - Set to enable S3 Server Side Encryption. Can be - either AES256 or aws:kms - --server-side-encryption-kms-key-id=SERVER-SIDE-ENCRYPTION-KMS-KEY-ID - Server-side KMS Key ID. Must be set if - server-side-encryption is set to aws:kms - --compression-codec=COMPRESSION-CODEC - The codec used for compression of your logs. - Valid values are zstd, snappy, and gzip. If the - specified codec is "gzip", gzip_level will - default to 3. To specify a different level, - leave compression_codec blank and explicitly - set the level using gzip_level. Specifying both - compression_codec and gzip_level in the same - API request will result in an error. - - logging s3 delete --version=VERSION --name=NAME [] - Delete a S3 logging endpoint on a Fastly service version - - --version=VERSION Number of service version - -n, --name=NAME The name of the S3 logging object - -s, --service-id=SERVICE-ID Service ID - - logging kinesis create --name=NAME --version=VERSION --stream-name=STREAM-NAME --region=REGION [] - Create an Amazon Kinesis logging endpoint on a Fastly service version - - -n, --name=NAME The name of the Kinesis logging object. Used - as a primary key for API access - --version=VERSION Number of service version - --stream-name=STREAM-NAME The Amazon Kinesis stream to send logs to - --region=REGION The AWS region where the Kinesis stream - exists - --access-key=ACCESS-KEY The access key associated with the target - Amazon Kinesis stream - --secret-key=SECRET-KEY The secret key associated with the target - Amazon Kinesis stream - --iam-role=IAM-ROLE The IAM role ARN for logging - -s, --service-id=SERVICE-ID Service ID - --format=FORMAT Apache style log formatting - --format-version=FORMAT-VERSION - The version of the custom logging format used - for the configured endpoint. Can be either 2 - (default) or 1 - --response-condition=RESPONSE-CONDITION - The name of an existing condition in the - configured endpoint, or leave blank to always - execute - --placement=PLACEMENT Where in the generated VCL the logging call - should be placed, overriding any - format_version default. Can be none or - waf_debug - - logging kinesis list --version=VERSION [] - List Kinesis endpoints on a Fastly service version - - -s, --service-id=SERVICE-ID Service ID - --version=VERSION Number of service version - - logging kinesis describe --version=VERSION --name=NAME [] - Show detailed information about a Kinesis logging endpoint on a Fastly - service version - - -s, --service-id=SERVICE-ID Service ID - --version=VERSION Number of service version - -n, --name=NAME The name of the Kinesis logging object - - logging kinesis update --version=VERSION --name=NAME [] - Update a Kinesis logging endpoint on a Fastly service version - - --version=VERSION Number of service version - -n, --name=NAME The name of the Kinesis logging object - -s, --service-id=SERVICE-ID Service ID - --new-name=NEW-NAME New name of the Kinesis logging object - --stream-name=STREAM-NAME Your Kinesis stream name - --access-key=ACCESS-KEY Your Kinesis account access key - --secret-key=SECRET-KEY Your Kinesis account secret key - --iam-role=IAM-ROLE The IAM role ARN for logging - --region=REGION The AWS region where the Kinesis stream - exists - --format=FORMAT Apache style log formatting - --format-version=FORMAT-VERSION - The version of the custom logging format used - for the configured endpoint. Can be either 2 - (default) or 1 - --response-condition=RESPONSE-CONDITION - The name of an existing condition in the - configured endpoint, or leave blank to always - execute - --placement=PLACEMENT Where in the generated VCL the logging call - should be placed, overriding any - format_version default. Can be none or - waf_debug - - logging kinesis delete --version=VERSION --name=NAME [] - Delete a Kinesis logging endpoint on a Fastly service version - - --version=VERSION Number of service version - -n, --name=NAME The name of the Kinesis logging object - -s, --service-id=SERVICE-ID Service ID - - logging syslog create --name=NAME --version=VERSION --address=ADDRESS [] - Create a Syslog logging endpoint on a Fastly service version - - -n, --name=NAME The name of the Syslog logging object. Used - as a primary key for API access - --version=VERSION Number of service version - --address=ADDRESS A hostname or IPv4 address - -s, --service-id=SERVICE-ID Service ID - --port=PORT The port number - --use-tls Whether to use TLS for secure logging. Can be - either true or false - --tls-ca-cert=TLS-CA-CERT A secure certificate to authenticate the - server with. Must be in PEM format - --tls-hostname=TLS-HOSTNAME - Used during the TLS handshake to validate the - certificate - --tls-client-cert=TLS-CLIENT-CERT - The client certificate used to make - authenticated requests. Must be in PEM format - --tls-client-key=TLS-CLIENT-KEY - The client private key used to make - authenticated requests. Must be in PEM format - --auth-token=AUTH-TOKEN Whether to prepend each message with a - specific token - --format=FORMAT Apache style log formatting - --format-version=FORMAT-VERSION - The version of the custom logging format used - for the configured endpoint. Can be either 2 - (default) or 1 - --message-type=MESSAGE-TYPE - How the message should be formatted. One of: - classic (default), loggly, logplex or blank - --response-condition=RESPONSE-CONDITION - The name of an existing condition in the - configured endpoint, or leave blank to always - execute - --placement=PLACEMENT Where in the generated VCL the logging call - should be placed, overriding any - format_version default. Can be none or - waf_debug - - logging syslog list --version=VERSION [] - List Syslog endpoints on a Fastly service version - - -s, --service-id=SERVICE-ID Service ID - --version=VERSION Number of service version - - logging syslog describe --version=VERSION --name=NAME [] - Show detailed information about a Syslog logging endpoint on a Fastly - service version - - -s, --service-id=SERVICE-ID Service ID - --version=VERSION Number of service version - -n, --name=NAME The name of the Syslog logging object - - logging syslog update --version=VERSION --name=NAME [] - Update a Syslog logging endpoint on a Fastly service version - - --version=VERSION Number of service version - -n, --name=NAME The name of the Syslog logging object - -s, --service-id=SERVICE-ID Service ID - --new-name=NEW-NAME New name of the Syslog logging object - --address=ADDRESS A hostname or IPv4 address - --port=PORT The port number - --use-tls Whether to use TLS for secure logging. Can be - either true or false - --tls-ca-cert=TLS-CA-CERT A secure certificate to authenticate the - server with. Must be in PEM format - --tls-hostname=TLS-HOSTNAME - Used during the TLS handshake to validate the - certificate - --tls-client-cert=TLS-CLIENT-CERT - The client certificate used to make - authenticated requests. Must be in PEM format - --tls-client-key=TLS-CLIENT-KEY - The client private key used to make - authenticated requests. Must be in PEM format - --auth-token=AUTH-TOKEN Whether to prepend each message with a - specific token - --format=FORMAT Apache style log formatting - --format-version=FORMAT-VERSION - The version of the custom logging format used - for the configured endpoint. Can be either 2 - (default) or 1 - --message-type=MESSAGE-TYPE - How the message should be formatted. One of: - classic (default), loggly, logplex or blank - --response-condition=RESPONSE-CONDITION - The name of an existing condition in the - configured endpoint, or leave blank to always - execute - --placement=PLACEMENT Where in the generated VCL the logging call - should be placed, overriding any - format_version default. Can be none or - waf_debug - - logging syslog delete --version=VERSION --name=NAME [] - Delete a Syslog logging endpoint on a Fastly service version - - --version=VERSION Number of service version - -n, --name=NAME The name of the Syslog logging object - -s, --service-id=SERVICE-ID Service ID - - logging logentries create --name=NAME --version=VERSION [] - Create a Logentries logging endpoint on a Fastly service version - - -n, --name=NAME The name of the Logentries logging object. Used - as a primary key for API access - --version=VERSION Number of service version - -s, --service-id=SERVICE-ID Service ID - --port=PORT The port number - --use-tls Whether to use TLS for secure logging. Can be - either true or false - --auth-token=AUTH-TOKEN Use token based authentication - (https://logentries.com/doc/input-token/) - --format=FORMAT Apache style log formatting - --format-version=FORMAT-VERSION - The version of the custom logging format used - for the configured endpoint. Can be either 2 - (the default, version 2 log format) or 1 (the - version 1 log format). The logging call gets - placed by default in vcl_log if format_version - is set to 2 and in vcl_deliver if - format_version is set to 1 - --response-condition=RESPONSE-CONDITION - The name of an existing condition in the - configured endpoint, or leave blank to always - execute - --placement=PLACEMENT Where in the generated VCL the logging call - should be placed, overriding any format_version - default. Can be none or waf_debug. This field - is not required and has no default value - - logging logentries list --version=VERSION [] - List Logentries endpoints on a Fastly service version - - -s, --service-id=SERVICE-ID Service ID - --version=VERSION Number of service version - - logging logentries describe --version=VERSION --name=NAME [] - Show detailed information about a Logentries logging endpoint on a Fastly - service version - - -s, --service-id=SERVICE-ID Service ID - --version=VERSION Number of service version - -n, --name=NAME The name of the Logentries logging object - - logging logentries update --version=VERSION --name=NAME [] - Update a Logentries logging endpoint on a Fastly service version - - --version=VERSION Number of service version - -n, --name=NAME The name of the Logentries logging object - -s, --service-id=SERVICE-ID Service ID - --new-name=NEW-NAME New name of the Logentries logging object - --port=PORT The port number - --use-tls Whether to use TLS for secure logging. Can be - either true or false - --auth-token=AUTH-TOKEN Use token based authentication - (https://logentries.com/doc/input-token/) - --format=FORMAT Apache style log formatting - --format-version=FORMAT-VERSION - The version of the custom logging format used - for the configured endpoint. Can be either 2 - (the default, version 2 log format) or 1 (the - version 1 log format). The logging call gets - placed by default in vcl_log if format_version - is set to 2 and in vcl_deliver if - format_version is set to 1 - --response-condition=RESPONSE-CONDITION - The name of an existing condition in the - configured endpoint, or leave blank to always - execute - --placement=PLACEMENT Where in the generated VCL the logging call - should be placed, overriding any format_version - default. Can be none or waf_debug. This field - is not required and has no default value - - logging logentries delete --version=VERSION --name=NAME [] - Delete a Logentries logging endpoint on a Fastly service version - - --version=VERSION Number of service version - -n, --name=NAME The name of the Logentries logging object - -s, --service-id=SERVICE-ID Service ID - - logging papertrail create --name=NAME --version=VERSION --address=ADDRESS [] - Create a Papertrail logging endpoint on a Fastly service version - - -n, --name=NAME The name of the Papertrail logging object. Used - as a primary key for API access - --version=VERSION Number of service version - --address=ADDRESS A hostname or IPv4 address - -s, --service-id=SERVICE-ID Service ID - --port=PORT The port number - --format-version=FORMAT-VERSION - The version of the custom logging format used - for the configured endpoint. Can be either 2 - (the default, version 2 log format) or 1 (the - version 1 log format). The logging call gets - placed by default in vcl_log if format_version - is set to 2 and in vcl_deliver if - format_version is set to 1 - --format=FORMAT Apache style log formatting - --response-condition=RESPONSE-CONDITION - The name of an existing condition in the - configured endpoint, or leave blank to always - execute - --placement=PLACEMENT Where in the generated VCL the logging call - should be placed, overriding any format_version - default. Can be none or waf_debug. This field - is not required and has no default value - - logging papertrail list --version=VERSION [] - List Papertrail endpoints on a Fastly service version - - -s, --service-id=SERVICE-ID Service ID - --version=VERSION Number of service version - - logging papertrail describe --version=VERSION --name=NAME [] - Show detailed information about a Papertrail logging endpoint on a Fastly - service version - - -s, --service-id=SERVICE-ID Service ID - --version=VERSION Number of service version - -n, --name=NAME The name of the Papertrail logging object - - logging papertrail update --version=VERSION --name=NAME [] - Update a Papertrail logging endpoint on a Fastly service version - - --version=VERSION Number of service version - -n, --name=NAME The name of the Papertrail logging object - -s, --service-id=SERVICE-ID Service ID - --new-name=NEW-NAME New name of the Papertrail logging object - --address=ADDRESS A hostname or IPv4 address - --port=PORT The port number - --format-version=FORMAT-VERSION - The version of the custom logging format used - for the configured endpoint. Can be either 2 - (the default, version 2 log format) or 1 (the - version 1 log format). The logging call gets - placed by default in vcl_log if format_version - is set to 2 and in vcl_deliver if - format_version is set to 1 - --format=FORMAT Apache style log formatting - --response-condition=RESPONSE-CONDITION - The name of an existing condition in the - configured endpoint, or leave blank to always - execute - --placement=PLACEMENT Where in the generated VCL the logging call - should be placed, overriding any format_version - default. Can be none or waf_debug. This field - is not required and has no default value - - logging papertrail delete --version=VERSION --name=NAME [] - Delete a Papertrail logging endpoint on a Fastly service version - - --version=VERSION Number of service version - -n, --name=NAME The name of the Papertrail logging object - -s, --service-id=SERVICE-ID Service ID - - logging sumologic create --name=NAME --version=VERSION --url=URL [] - Create a Sumologic logging endpoint on a Fastly service version - - -n, --name=NAME The name of the Sumologic logging object. Used - as a primary key for API access - --version=VERSION Number of service version - --url=URL The URL to POST to - -s, --service-id=SERVICE-ID Service ID - --format=FORMAT Apache style log formatting - --format-version=FORMAT-VERSION - The version of the custom logging format used - for the configured endpoint. Can be either 2 - (the default, version 2 log format) or 1 (the - version 1 log format). The logging call gets - placed by default in vcl_log if format_version - is set to 2 and in vcl_deliver if - format_version is set to 1 - --response-condition=RESPONSE-CONDITION - The name of an existing condition in the - configured endpoint, or leave blank to always - execute - --message-type=MESSAGE-TYPE - How the message should be formatted. One of: - classic (default), loggly, logplex or blank - --placement=PLACEMENT Where in the generated VCL the logging call - should be placed, overriding any format_version - default. Can be none or waf_debug. This field - is not required and has no default value - - logging sumologic list --version=VERSION [] - List Sumologic endpoints on a Fastly service version - - -s, --service-id=SERVICE-ID Service ID - --version=VERSION Number of service version - - logging sumologic describe --version=VERSION --name=NAME [] - Show detailed information about a Sumologic logging endpoint on a Fastly - service version - - -s, --service-id=SERVICE-ID Service ID - --version=VERSION Number of service version - -n, --name=NAME The name of the Sumologic logging object - - logging sumologic update --version=VERSION --name=NAME [] - Update a Sumologic logging endpoint on a Fastly service version - - --version=VERSION Number of service version - -n, --name=NAME The name of the Sumologic logging object - -s, --service-id=SERVICE-ID Service ID - --new-name=NEW-NAME New name of the Sumologic logging object - --url=URL The URL to POST to - --format=FORMAT Apache style log formatting - --format-version=FORMAT-VERSION - The version of the custom logging format used - for the configured endpoint. Can be either 2 - (the default, version 2 log format) or 1 (the - version 1 log format). The logging call gets - placed by default in vcl_log if format_version - is set to 2 and in vcl_deliver if - format_version is set to 1 - --response-condition=RESPONSE-CONDITION - The name of an existing condition in the - configured endpoint, or leave blank to always - execute - --message-type=MESSAGE-TYPE - How the message should be formatted. One of: - classic (default), loggly, logplex or blank - --placement=PLACEMENT Where in the generated VCL the logging call - should be placed, overriding any format_version - default. Can be none or waf_debug. This field - is not required and has no default value - - logging sumologic delete --version=VERSION --name=NAME [] - Delete a Sumologic logging endpoint on a Fastly service version - - --version=VERSION Number of service version - -n, --name=NAME The name of the Sumologic logging object - -s, --service-id=SERVICE-ID Service ID - - logging gcs create --name=NAME --version=VERSION --user=USER --bucket=BUCKET --secret-key=SECRET-KEY [] - Create a GCS logging endpoint on a Fastly service version - - -n, --name=NAME The name of the GCS logging object. Used as a - primary key for API access - --version=VERSION Number of service version - --user=USER Your GCS service account email address. The - client_email field in your service account - authentication JSON - --bucket=BUCKET The bucket of the GCS bucket - --secret-key=SECRET-KEY Your GCS account secret key. The private_key - field in your service account authentication - JSON - -s, --service-id=SERVICE-ID Service ID - --period=PERIOD How frequently log files are finalized so they - can be available for reading (in seconds, - default 3600) - --path=PATH The path to upload logs to (default '/') - --gzip-level=GZIP-LEVEL What level of GZIP encoding to have when - dumping logs (default 0, no compression) - --format=FORMAT Apache style log formatting - --format-version=FORMAT-VERSION - The version of the custom logging format used - for the configured endpoint. Can be either 2 - (the default, version 2 log format) or 1 (the - version 1 log format). The logging call gets - placed by default in vcl_log if format_version - is set to 2 and in vcl_deliver if - format_version is set to 1 - --message-type=MESSAGE-TYPE - How the message should be formatted. One of: - classic (default), loggly, logplex or blank - --response-condition=RESPONSE-CONDITION - The name of an existing condition in the - configured endpoint, or leave blank to always - execute - --timestamp-format=TIMESTAMP-FORMAT - strftime specified timestamp formatting - (default "%Y-%m-%dT%H:%M:%S.000") - --placement=PLACEMENT Where in the generated VCL the logging call - should be placed, overriding any format_version - default. Can be none or waf_debug - --compression-codec=COMPRESSION-CODEC - The codec used for compression of your logs. - Valid values are zstd, snappy, and gzip. If the - specified codec is "gzip", gzip_level will - default to 3. To specify a different level, - leave compression_codec blank and explicitly - set the level using gzip_level. Specifying both - compression_codec and gzip_level in the same - API request will result in an error. - - logging gcs list --version=VERSION [] - List GCS endpoints on a Fastly service version - - -s, --service-id=SERVICE-ID Service ID - --version=VERSION Number of service version - - logging gcs describe --version=VERSION --name=NAME [] - Show detailed information about a GCS logging endpoint on a Fastly service - version - - -s, --service-id=SERVICE-ID Service ID - --version=VERSION Number of service version - -n, --name=NAME The name of the GCS logging object - - logging gcs update --version=VERSION --name=NAME [] - Update a GCS logging endpoint on a Fastly service version - - --version=VERSION Number of service version - -n, --name=NAME The name of the GCS logging object - -s, --service-id=SERVICE-ID Service ID - --new-name=NEW-NAME New name of the GCS logging object - --bucket=BUCKET The bucket of the GCS bucket - --user=USER Your GCS service account email address. The - client_email field in your service account - authentication JSON - --secret-key=SECRET-KEY Your GCS account secret key. The private_key - field in your service account authentication - JSON - --path=PATH The path to upload logs to (default '/') - --period=PERIOD How frequently log files are finalized so they - can be available for reading (in seconds, - default 3600) - --format-version=FORMAT-VERSION - The version of the custom logging format used - for the configured endpoint. Can be either 2 - (the default, version 2 log format) or 1 (the - version 1 log format). The logging call gets - placed by default in vcl_log if format_version - is set to 2 and in vcl_deliver if - format_version is set to 1 - --gzip-level=GZIP-LEVEL What level of GZIP encoding to have when - dumping logs (default 0, no compression) - --format=FORMAT Apache style log formatting - --response-condition=RESPONSE-CONDITION - The name of an existing condition in the - configured endpoint, or leave blank to always - execute - --timestamp-format=TIMESTAMP-FORMAT - strftime specified timestamp formatting - (default "%Y-%m-%dT%H:%M:%S.000") - --message-type=MESSAGE-TYPE - How the message should be formatted. One of: - classic (default), loggly, logplex or blank - --placement=PLACEMENT Where in the generated VCL the logging call - should be placed, overriding any format_version - default. Can be none or waf_debug - --compression-codec=COMPRESSION-CODEC - The codec used for compression of your logs. - Valid values are zstd, snappy, and gzip. If the - specified codec is "gzip", gzip_level will - default to 3. To specify a different level, - leave compression_codec blank and explicitly - set the level using gzip_level. Specifying both - compression_codec and gzip_level in the same - API request will result in an error. - - logging gcs delete --version=VERSION --name=NAME [] - Delete a GCS logging endpoint on a Fastly service version - - --version=VERSION Number of service version - -n, --name=NAME The name of the GCS logging object - -s, --service-id=SERVICE-ID Service ID - - logging ftp create --name=NAME --version=VERSION --address=ADDRESS --user=USER --password=PASSWORD [] - Create an FTP logging endpoint on a Fastly service version - - -n, --name=NAME The name of the FTP logging object. Used as a - primary key for API access - --version=VERSION Number of service version - --address=ADDRESS An hostname or IPv4 address - --user=USER The username for the server (can be anonymous) - --password=PASSWORD The password for the server (for anonymous use - an email address) - -s, --service-id=SERVICE-ID Service ID - --port=PORT The port number - --path=PATH The path to upload log files to. If the path - ends in / then it is treated as a directory - --period=PERIOD How frequently log files are finalized so they - can be available for reading (in seconds, - default 3600) - --gzip-level=GZIP-LEVEL What level of GZIP encoding to have when - dumping logs (default 0, no compression) - --format=FORMAT Apache style log formatting - --format-version=FORMAT-VERSION - The version of the custom logging format used - for the configured endpoint. Can be either 2 - (default) or 1 - --response-condition=RESPONSE-CONDITION - The name of an existing condition in the - configured endpoint, or leave blank to always - execute - --timestamp-format=TIMESTAMP-FORMAT - strftime specified timestamp formatting - (default "%Y-%m-%dT%H:%M:%S.000") - --placement=PLACEMENT Where in the generated VCL the logging call - should be placed, overriding any format_version - default. Can be none or waf_debug - --compression-codec=COMPRESSION-CODEC - The codec used for compression of your logs. - Valid values are zstd, snappy, and gzip. If the - specified codec is "gzip", gzip_level will - default to 3. To specify a different level, - leave compression_codec blank and explicitly - set the level using gzip_level. Specifying both - compression_codec and gzip_level in the same - API request will result in an error. - - logging ftp list --version=VERSION [] - List FTP endpoints on a Fastly service version - - -s, --service-id=SERVICE-ID Service ID - --version=VERSION Number of service version - - logging ftp describe --version=VERSION --name=NAME [] - Show detailed information about an FTP logging endpoint on a Fastly service - version - - -s, --service-id=SERVICE-ID Service ID - --version=VERSION Number of service version - -n, --name=NAME The name of the FTP logging object - - logging ftp update --version=VERSION --name=NAME [] - Update an FTP logging endpoint on a Fastly service version - - --version=VERSION Number of service version - -n, --name=NAME The name of the FTP logging object - -s, --service-id=SERVICE-ID Service ID - --new-name=NEW-NAME New name of the FTP logging object - --address=ADDRESS An hostname or IPv4 address - --port=PORT The port number - --username=USERNAME The username for the server (can be anonymous) - --password=PASSWORD The password for the server (for anonymous use - an email address) - --public-key=PUBLIC-KEY A PGP public key that Fastly will use to - encrypt your log files before writing them to - disk - --path=PATH The path to upload log files to. If the path - ends in / then it is treated as a directory - --period=PERIOD How frequently log files are finalized so they - can be available for reading (in seconds, - default 3600) - --gzip-level=GZIP-LEVEL What level of GZIP encoding to have when - dumping logs (default 0, no compression) - --format=FORMAT Apache style log formatting - --format-version=FORMAT-VERSION - The version of the custom logging format used - for the configured endpoint. Can be either 2 - (the default, version 2 log format) or 1 (the - version 1 log format). The logging call gets - placed by default in vcl_log if format_version - is set to 2 and in vcl_deliver if - format_version is set to 1 - --response-condition=RESPONSE-CONDITION - The name of an existing condition in the - configured endpoint, or leave blank to always - execute - --timestamp-format=TIMESTAMP-FORMAT - strftime specified timestamp formatting - (default "%Y-%m-%dT%H:%M:%S.000") - --placement=PLACEMENT Where in the generated VCL the logging call - should be placed, overriding any format_version - default. Can be none or waf_debug - --compression-codec=COMPRESSION-CODEC - The codec used for compression of your logs. - Valid values are zstd, snappy, and gzip. If the - specified codec is "gzip", gzip_level will - default to 3. To specify a different level, - leave compression_codec blank and explicitly - set the level using gzip_level. Specifying both - compression_codec and gzip_level in the same - API request will result in an error. - - logging ftp delete --version=VERSION --name=NAME [] - Delete an FTP logging endpoint on a Fastly service version - - --version=VERSION Number of service version - -n, --name=NAME The name of the FTP logging object - -s, --service-id=SERVICE-ID Service ID - - logging splunk create --name=NAME --version=VERSION --url=URL [] - Create a Splunk logging endpoint on a Fastly service version - - -n, --name=NAME The name of the Splunk logging object. Used - as a primary key for API access - --version=VERSION Number of service version - --url=URL The URL to POST to - -s, --service-id=SERVICE-ID Service ID - --tls-ca-cert=TLS-CA-CERT A secure certificate to authenticate the - server with. Must be in PEM format - --tls-hostname=TLS-HOSTNAME - The hostname used to verify the server's - certificate. It can either be the Common Name - or a Subject Alternative Name (SAN) - --tls-client-cert=TLS-CLIENT-CERT - The client certificate used to make - authenticated requests. Must be in PEM format - --tls-client-key=TLS-CLIENT-KEY - The client private key used to make - authenticated requests. Must be in PEM format - --format=FORMAT Apache style log formatting - --format-version=FORMAT-VERSION - The version of the custom logging format used - for the configured endpoint. Can be either 2 - (default) or 1 - --response-condition=RESPONSE-CONDITION - The name of an existing condition in the - configured endpoint, or leave blank to always - execute - --placement=PLACEMENT Where in the generated VCL the logging call - should be placed, overriding any - format_version default. Can be none or - waf_debug - --auth-token=AUTH-TOKEN A Splunk token for use in posting logs over - HTTP to your collector - - logging splunk list --version=VERSION [] - List Splunk endpoints on a Fastly service version - - -s, --service-id=SERVICE-ID Service ID - --version=VERSION Number of service version - - logging splunk describe --version=VERSION --name=NAME [] - Show detailed information about a Splunk logging endpoint on a Fastly - service version - - -s, --service-id=SERVICE-ID Service ID - --version=VERSION Number of service version - -n, --name=NAME The name of the Splunk logging object - - logging splunk update --version=VERSION --name=NAME [] - Update a Splunk logging endpoint on a Fastly service version - - --version=VERSION Number of service version - -n, --name=NAME The name of the Splunk logging object - -s, --service-id=SERVICE-ID Service ID - --new-name=NEW-NAME New name of the Splunk logging object - --url=URL The URL to POST to. - --tls-ca-cert=TLS-CA-CERT A secure certificate to authenticate the - server with. Must be in PEM format - --tls-hostname=TLS-HOSTNAME - The hostname used to verify the server's - certificate. It can either be the Common Name - or a Subject Alternative Name (SAN) - --tls-client-cert=TLS-CLIENT-CERT - The client certificate used to make - authenticated requests. Must be in PEM format - --tls-client-key=TLS-CLIENT-KEY - The client private key used to make - authenticated requests. Must be in PEM format - --format=FORMAT Apache style log formatting - --format-version=FORMAT-VERSION - The version of the custom logging format used - for the configured endpoint. Can be either 2 - (default) or 1 - --response-condition=RESPONSE-CONDITION - The name of an existing condition in the - configured endpoint, or leave blank to always - execute - --placement=PLACEMENT Where in the generated VCL the logging call - should be placed, overriding any - format_version default. Can be none or - waf_debug. This field is not required and has - no default value - --auth-token=AUTH-TOKEN - - logging splunk delete --version=VERSION --name=NAME [] - Delete a Splunk logging endpoint on a Fastly service version - - --version=VERSION Number of service version - -n, --name=NAME The name of the Splunk logging object - -s, --service-id=SERVICE-ID Service ID - - logging scalyr create --name=NAME --version=VERSION --auth-token=AUTH-TOKEN [] - Create a Scalyr logging endpoint on a Fastly service version - - -n, --name=NAME The name of the Scalyr logging object. Used as - a primary key for API access - --version=VERSION Number of service version - --auth-token=AUTH-TOKEN The token to use for authentication - (https://www.scalyr.com/keys) - -s, --service-id=SERVICE-ID Service ID - --region=REGION The region that log data will be sent to. One - of US or EU. Defaults to US if undefined - --format=FORMAT Apache style log formatting - --format-version=FORMAT-VERSION - The version of the custom logging format used - for the configured endpoint. Can be either 2 - (default) or 1 - --response-condition=RESPONSE-CONDITION - The name of an existing condition in the - configured endpoint, or leave blank to always - execute - --placement=PLACEMENT Where in the generated VCL the logging call - should be placed, overriding any format_version - default. Can be none or waf_debug - - logging scalyr list --version=VERSION [] - List Scalyr endpoints on a Fastly service version - - -s, --service-id=SERVICE-ID Service ID - --version=VERSION Number of service version - - logging scalyr describe --version=VERSION --name=NAME [] - Show detailed information about a Scalyr logging endpoint on a Fastly - service version - - -s, --service-id=SERVICE-ID Service ID - --version=VERSION Number of service version - -n, --name=NAME The name of the Scalyr logging object - - logging scalyr update --version=VERSION --name=NAME [] - Update a Scalyr logging endpoint on a Fastly service version - - --version=VERSION Number of service version - -n, --name=NAME The name of the Scalyr logging object - -s, --service-id=SERVICE-ID Service ID - --new-name=NEW-NAME New name of the Scalyr logging object - --format=FORMAT Apache style log formatting - --format-version=FORMAT-VERSION - The version of the custom logging format used - for the configured endpoint. Can be either 2 - (default) or 1 - --auth-token=AUTH-TOKEN The token to use for authentication - (https://www.scalyr.com/keys) - --region=REGION The region that log data will be sent to. One - of US or EU. Defaults to US if undefined - --response-condition=RESPONSE-CONDITION - The name of an existing condition in the - configured endpoint, or leave blank to always - execute - --placement=PLACEMENT Where in the generated VCL the logging call - should be placed, overriding any format_version - default. Can be none or waf_debug - - logging scalyr delete --version=VERSION --name=NAME [] - Delete a Scalyr logging endpoint on a Fastly service version - - --version=VERSION Number of service version - -n, --name=NAME The name of the Scalyr logging object - -s, --service-id=SERVICE-ID Service ID - - logging loggly create --name=NAME --version=VERSION --auth-token=AUTH-TOKEN [] - Create a Loggly logging endpoint on a Fastly service version - - -n, --name=NAME The name of the Loggly logging object. Used as - a primary key for API access - --version=VERSION Number of service version - --auth-token=AUTH-TOKEN The token to use for authentication - (https://www.loggly.com/docs/customer-token-authentication-token/) - -s, --service-id=SERVICE-ID Service ID - --format=FORMAT Apache style log formatting - --format-version=FORMAT-VERSION - The version of the custom logging format used - for the configured endpoint. Can be either 2 - (default) or 1 - --response-condition=RESPONSE-CONDITION - The name of an existing condition in the - configured endpoint, or leave blank to always - execute - --placement=PLACEMENT Where in the generated VCL the logging call - should be placed, overriding any format_version - default. Can be none or waf_debug - - logging loggly list --version=VERSION [] - List Loggly endpoints on a Fastly service version - - -s, --service-id=SERVICE-ID Service ID - --version=VERSION Number of service version - - logging loggly describe --version=VERSION --name=NAME [] - Show detailed information about a Loggly logging endpoint on a Fastly - service version - - -s, --service-id=SERVICE-ID Service ID - --version=VERSION Number of service version - -n, --name=NAME The name of the Loggly logging object - - logging loggly update --version=VERSION --name=NAME [] - Update a Loggly logging endpoint on a Fastly service version - - --version=VERSION Number of service version - -n, --name=NAME The name of the Loggly logging object - -s, --service-id=SERVICE-ID Service ID - --new-name=NEW-NAME New name of the Loggly logging object - --auth-token=AUTH-TOKEN The token to use for authentication - (https://www.loggly.com/docs/customer-token-authentication-token/) - --format=FORMAT Apache style log formatting - --format-version=FORMAT-VERSION - The version of the custom logging format used - for the configured endpoint. Can be either 2 - (default) or 1 - --response-condition=RESPONSE-CONDITION - The name of an existing condition in the - configured endpoint, or leave blank to always - execute - --placement=PLACEMENT Where in the generated VCL the logging call - should be placed, overriding any format_version - default. Can be none or waf_debug - - logging loggly delete --version=VERSION --name=NAME [] - Delete a Loggly logging endpoint on a Fastly service version - - --version=VERSION Number of service version - -n, --name=NAME The name of the Loggly logging object - -s, --service-id=SERVICE-ID Service ID - - logging honeycomb create --name=NAME --version=VERSION --dataset=DATASET --auth-token=AUTH-TOKEN [] - Create a Honeycomb logging endpoint on a Fastly service version - - -n, --name=NAME The name of the Honeycomb logging object. Used - as a primary key for API access - --version=VERSION Number of service version - --dataset=DATASET The Honeycomb Dataset you want to log to - --auth-token=AUTH-TOKEN The Write Key from the Account page of your - Honeycomb account - -s, --service-id=SERVICE-ID Service ID - --format=FORMAT Apache style log formatting. Your log must - produce valid JSON that Honeycomb can ingest - --format-version=FORMAT-VERSION - The version of the custom logging format used - for the configured endpoint. Can be either 2 - (default) or 1 - --response-condition=RESPONSE-CONDITION - The name of an existing condition in the - configured endpoint, or leave blank to always - execute - --placement=PLACEMENT Where in the generated VCL the logging call - should be placed, overriding any format_version - default. Can be none or waf_debug - - logging honeycomb list --version=VERSION [] - List Honeycomb endpoints on a Fastly service version - - -s, --service-id=SERVICE-ID Service ID - --version=VERSION Number of service version - - logging honeycomb describe --version=VERSION --name=NAME [] - Show detailed information about a Honeycomb logging endpoint on a Fastly - service version - - -s, --service-id=SERVICE-ID Service ID - --version=VERSION Number of service version - -n, --name=NAME The name of the Honeycomb logging object - - logging honeycomb update --version=VERSION --name=NAME [] - Update a Honeycomb logging endpoint on a Fastly service version - - --version=VERSION Number of service version - -n, --name=NAME The name of the Honeycomb logging object - -s, --service-id=SERVICE-ID Service ID - --new-name=NEW-NAME New name of the Honeycomb logging object - --format=FORMAT Apache style log formatting. Your log must - produce valid JSON that Honeycomb can ingest - --format-version=FORMAT-VERSION - The version of the custom logging format used - for the configured endpoint. Can be either 2 - (default) or 1 - --dataset=DATASET The Honeycomb Dataset you want to log to - --auth-token=AUTH-TOKEN The Write Key from the Account page of your - Honeycomb account - --response-condition=RESPONSE-CONDITION - The name of an existing condition in the - configured endpoint, or leave blank to always - execute - --placement=PLACEMENT Where in the generated VCL the logging call - should be placed, overriding any format_version - default. Can be none or waf_debug - - logging honeycomb delete --version=VERSION --name=NAME [] - Delete a Honeycomb logging endpoint on a Fastly service version - - --version=VERSION Number of service version - -n, --name=NAME The name of the Honeycomb logging object - -s, --service-id=SERVICE-ID Service ID - - logging heroku create --name=NAME --version=VERSION --url=URL --auth-token=AUTH-TOKEN [] - Create a Heroku logging endpoint on a Fastly service version - - -n, --name=NAME The name of the Heroku logging object. Used as - a primary key for API access - --version=VERSION Number of service version - --url=URL The url to stream logs to - --auth-token=AUTH-TOKEN The token to use for authentication - (https://devcenter.heroku.com/articles/add-on-partner-log-integration) - -s, --service-id=SERVICE-ID Service ID - --format=FORMAT Apache style log formatting - --format-version=FORMAT-VERSION - The version of the custom logging format used - for the configured endpoint. Can be either 2 - (default) or 1 - --response-condition=RESPONSE-CONDITION - The name of an existing condition in the - configured endpoint, or leave blank to always - execute - --placement=PLACEMENT Where in the generated VCL the logging call - should be placed, overriding any format_version - default. Can be none or waf_debug - - logging heroku list --version=VERSION [] - List Heroku endpoints on a Fastly service version - - -s, --service-id=SERVICE-ID Service ID - --version=VERSION Number of service version - - logging heroku describe --version=VERSION --name=NAME [] - Show detailed information about a Heroku logging endpoint on a Fastly - service version - - -s, --service-id=SERVICE-ID Service ID - --version=VERSION Number of service version - -n, --name=NAME The name of the Heroku logging object - - logging heroku update --version=VERSION --name=NAME [] - Update a Heroku logging endpoint on a Fastly service version - - --version=VERSION Number of service version - -n, --name=NAME The name of the Heroku logging object - -s, --service-id=SERVICE-ID Service ID - --new-name=NEW-NAME New name of the Heroku logging object - --format=FORMAT Apache style log formatting - --format-version=FORMAT-VERSION - The version of the custom logging format used - for the configured endpoint. Can be either 2 - (default) or 1 - --url=URL The url to stream logs to - --auth-token=AUTH-TOKEN The token to use for authentication - (https://devcenter.heroku.com/articles/add-on-partner-log-integration) - --response-condition=RESPONSE-CONDITION - The name of an existing condition in the - configured endpoint, or leave blank to always - execute - --placement=PLACEMENT Where in the generated VCL the logging call - should be placed, overriding any format_version - default. Can be none or waf_debug - - logging heroku delete --version=VERSION --name=NAME [] - Delete a Heroku logging endpoint on a Fastly service version - - --version=VERSION Number of service version - -n, --name=NAME The name of the Heroku logging object - -s, --service-id=SERVICE-ID Service ID - - logging sftp create --name=NAME --version=VERSION --address=ADDRESS --user=USER --ssh-known-hosts=SSH-KNOWN-HOSTS [] - Create an SFTP logging endpoint on a Fastly service version - - -n, --name=NAME The name of the SFTP logging object. Used as a - primary key for API access - --version=VERSION Number of service version - --address=ADDRESS The hostname or IPv4 addres - --user=USER The username for the server - --ssh-known-hosts=SSH-KNOWN-HOSTS - A list of host keys for all hosts we can - connect to over SFTP - -s, --service-id=SERVICE-ID Service ID - --port=PORT The port number - --password=PASSWORD The password for the server. If both password - and secret_key are passed, secret_key will be - used in preference - --public-key=PUBLIC-KEY A PGP public key that Fastly will use to - encrypt your log files before writing them to - disk - --secret-key=SECRET-KEY The SSH private key for the server. If both - password and secret_key are passed, secret_key - will be used in preference - --path=PATH The path to upload logs to. The directory must - exist on the SFTP server before logs can be - saved to it - --period=PERIOD How frequently log files are finalized so they - can be available for reading (in seconds, - default 3600) - --format=FORMAT Apache style log formatting - --format-version=FORMAT-VERSION - The version of the custom logging format used - for the configured endpoint. Can be either 2 - (default) or 1 - --gzip-level=GZIP-LEVEL What level of GZIP encoding to have when - dumping logs (default 0, no compression) - --message-type=MESSAGE-TYPE - How the message should be formatted. One of: - classic (default), loggly, logplex or blank - --response-condition=RESPONSE-CONDITION - The name of an existing condition in the - configured endpoint, or leave blank to always - execute - --timestamp-format=TIMESTAMP-FORMAT - strftime specified timestamp formatting - (default "%Y-%m-%dT%H:%M:%S.000") - --placement=PLACEMENT Where in the generated VCL the logging call - should be placed, overriding any format_version - default. Can be none or waf_debug - --compression-codec=COMPRESSION-CODEC - The codec used for compression of your logs. - Valid values are zstd, snappy, and gzip. If the - specified codec is "gzip", gzip_level will - default to 3. To specify a different level, - leave compression_codec blank and explicitly - set the level using gzip_level. Specifying both - compression_codec and gzip_level in the same - API request will result in an error. - - logging sftp list --version=VERSION [] - List SFTP endpoints on a Fastly service version - - -s, --service-id=SERVICE-ID Service ID - --version=VERSION Number of service version - - logging sftp describe --version=VERSION --name=NAME [] - Show detailed information about an SFTP logging endpoint on a Fastly service - version - - -s, --service-id=SERVICE-ID Service ID - --version=VERSION Number of service version - -n, --name=NAME The name of the SFTP logging object - - logging sftp update --version=VERSION --name=NAME [] - Update an SFTP logging endpoint on a Fastly service version - - --version=VERSION Number of service version - -n, --name=NAME The name of the SFTP logging object - -s, --service-id=SERVICE-ID Service ID - --new-name=NEW-NAME New name of the SFTP logging object - --address=ADDRESS The hostname or IPv4 address - --port=PORT The port number - --public-key=PUBLIC-KEY A PGP public key that Fastly will use to - encrypt your log files before writing them to - disk - --secret-key=SECRET-KEY The SSH private key for the server. If both - password and secret_key are passed, secret_key - will be used in preference - --ssh-known-hosts=SSH-KNOWN-HOSTS - A list of host keys for all hosts we can - connect to over SFTP - --user=USER The username for the server - --password=PASSWORD The password for the server. If both password - and secret_key are passed, secret_key will be - used in preference - --path=PATH The path to upload logs to. The directory must - exist on the SFTP server before logs can be - saved to it - --period=PERIOD How frequently log files are finalized so they - can be available for reading (in seconds, - default 3600) - --format=FORMAT Apache style log formatting - --format-version=FORMAT-VERSION - The version of the custom logging format used - for the configured endpoint. Can be either 2 - (default) or 1 - --message-type=MESSAGE-TYPE - How the message should be formatted. One of: - classic (default), loggly, logplex or blank - --gzip-level=GZIP-LEVEL What level of GZIP encoding to have when - dumping logs (default 0, no compression) - --response-condition=RESPONSE-CONDITION - The name of an existing condition in the - configured endpoint, or leave blank to always - execute - --timestamp-format=TIMESTAMP-FORMAT - strftime specified timestamp formatting - (default "%Y-%m-%dT%H:%M:%S.000") - --placement=PLACEMENT Where in the generated VCL the logging call - should be placed, overriding any format_version - default. Can be none or waf_debug - --compression-codec=COMPRESSION-CODEC - The codec used for compression of your logs. - Valid values are zstd, snappy, and gzip. If the - specified codec is "gzip", gzip_level will - default to 3. To specify a different level, - leave compression_codec blank and explicitly - set the level using gzip_level. Specifying both - compression_codec and gzip_level in the same - API request will result in an error. - - logging sftp delete --version=VERSION --name=NAME [] - Delete an SFTP logging endpoint on a Fastly service version - - --version=VERSION Number of service version - -n, --name=NAME The name of the SFTP logging object - -s, --service-id=SERVICE-ID Service ID - - logging logshuttle create --name=NAME --version=VERSION --url=URL --auth-token=AUTH-TOKEN [] - Create a Logshuttle logging endpoint on a Fastly service version - - -n, --name=NAME The name of the Logshuttle logging object. Used - as a primary key for API access - --version=VERSION Number of service version - --url=URL Your Log Shuttle endpoint url - --auth-token=AUTH-TOKEN The data authentication token associated with - this endpoint - -s, --service-id=SERVICE-ID Service ID - --format=FORMAT Apache style log formatting - --format-version=FORMAT-VERSION - The version of the custom logging format used - for the configured endpoint. Can be either 2 - (default) or 1 - --response-condition=RESPONSE-CONDITION - The name of an existing condition in the - configured endpoint, or leave blank to always - execute - --placement=PLACEMENT Where in the generated VCL the logging call - should be placed, overriding any format_version - default. Can be none or waf_debug - - logging logshuttle list --version=VERSION [] - List Logshuttle endpoints on a Fastly service version - - -s, --service-id=SERVICE-ID Service ID - --version=VERSION Number of service version - - logging logshuttle describe --version=VERSION --name=NAME [] - Show detailed information about a Logshuttle logging endpoint on a Fastly - service version - - -s, --service-id=SERVICE-ID Service ID - --version=VERSION Number of service version - -n, --name=NAME The name of the Logshuttle logging object - - logging logshuttle update --version=VERSION --name=NAME [] - Update a Logshuttle logging endpoint on a Fastly service version - - --version=VERSION Number of service version - -n, --name=NAME The name of the Logshuttle logging object - -s, --service-id=SERVICE-ID Service ID - --new-name=NEW-NAME New name of the Logshuttle logging object - --format=FORMAT Apache style log formatting - --format-version=FORMAT-VERSION - The version of the custom logging format used - for the configured endpoint. Can be either 2 - (default) or 1 - --url=URL Your Log Shuttle endpoint url - --auth-token=AUTH-TOKEN The data authentication token associated with - this endpoint - --response-condition=RESPONSE-CONDITION - The name of an existing condition in the - configured endpoint, or leave blank to always - execute - --placement=PLACEMENT Where in the generated VCL the logging call - should be placed, overriding any format_version - default. Can be none or waf_debug - - logging logshuttle delete --version=VERSION --name=NAME [] - Delete a Logshuttle logging endpoint on a Fastly service version - - --version=VERSION Number of service version - -n, --name=NAME The name of the Logshuttle logging object - -s, --service-id=SERVICE-ID Service ID - - logging cloudfiles create --name=NAME --version=VERSION --user=USER --access-key=ACCESS-KEY --bucket=BUCKET [] - Create a Cloudfiles logging endpoint on a Fastly service version - - -n, --name=NAME The name of the Cloudfiles logging object. Used - as a primary key for API access - --version=VERSION Number of service version - --user=USER The username for your Cloudfile account - --access-key=ACCESS-KEY Your Cloudfile account access key - --bucket=BUCKET The name of your Cloudfiles container - -s, --service-id=SERVICE-ID Service ID - --path=PATH The path to upload logs to - --region=REGION The region to stream logs to. One of: - DFW-Dallas, ORD-Chicago, IAD-Northern Virginia, - LON-London, SYD-Sydney, HKG-Hong Kong - --placement=PLACEMENT Where in the generated VCL the logging call - should be placed, overriding any format_version - default. Can be none or waf_debug - --period=PERIOD How frequently log files are finalized so they - can be available for reading (in seconds, - default 3600) - --gzip-level=GZIP-LEVEL What level of GZIP encoding to have when - dumping logs (default 0, no compression) - --format=FORMAT Apache style log formatting - --format-version=FORMAT-VERSION - The version of the custom logging format used - for the configured endpoint. Can be either 2 - (default) or 1 - --response-condition=RESPONSE-CONDITION - The name of an existing condition in the - configured endpoint, or leave blank to always - execute - --message-type=MESSAGE-TYPE - How the message should be formatted. One of: - classic (default), loggly, logplex or blank - --timestamp-format=TIMESTAMP-FORMAT - strftime specified timestamp formatting - (default "%Y-%m-%dT%H:%M:%S.000") - --public-key=PUBLIC-KEY A PGP public key that Fastly will use to - encrypt your log files before writing them to - disk - --compression-codec=COMPRESSION-CODEC - The codec used for compression of your logs. - Valid values are zstd, snappy, and gzip. If the - specified codec is "gzip", gzip_level will - default to 3. To specify a different level, - leave compression_codec blank and explicitly - set the level using gzip_level. Specifying both - compression_codec and gzip_level in the same - API request will result in an error. - - logging cloudfiles list --version=VERSION [] - List Cloudfiles endpoints on a Fastly service version - - -s, --service-id=SERVICE-ID Service ID - --version=VERSION Number of service version - - logging cloudfiles describe --version=VERSION --name=NAME [] - Show detailed information about a Cloudfiles logging endpoint on a Fastly - service version - - -s, --service-id=SERVICE-ID Service ID - --version=VERSION Number of service version - -n, --name=NAME The name of the Cloudfiles logging object - - logging cloudfiles update --version=VERSION --name=NAME [] - Update a Cloudfiles logging endpoint on a Fastly service version - - --version=VERSION Number of service version - -n, --name=NAME The name of the Cloudfiles logging object - -s, --service-id=SERVICE-ID Service ID - --new-name=NEW-NAME New name of the Cloudfiles logging object - --user=USER The username for your Cloudfile account - --access-key=ACCESS-KEY Your Cloudfile account access key - --bucket=BUCKET The name of your Cloudfiles container - --path=PATH The path to upload logs to - --region=REGION The region to stream logs to. One of: - DFW-Dallas, ORD-Chicago, IAD-Northern Virginia, - LON-London, SYD-Sydney, HKG-Hong Kong - --placement=PLACEMENT Where in the generated VCL the logging call - should be placed, overriding any format_version - default. Can be none or waf_debug - --period=PERIOD How frequently log files are finalized so they - can be available for reading (in seconds, - default 3600) - --gzip-level=GZIP-LEVEL What level of GZIP encoding to have when - dumping logs (default 0, no compression) - --format=FORMAT Apache style log formatting - --format-version=FORMAT-VERSION - The version of the custom logging format used - for the configured endpoint. Can be either 2 - (default) or 1 - --response-condition=RESPONSE-CONDITION - The name of an existing condition in the - configured endpoint, or leave blank to always - execute - --message-type=MESSAGE-TYPE - How the message should be formatted. One of: - classic (default), loggly, logplex or blank - --timestamp-format=TIMESTAMP-FORMAT - strftime specified timestamp formatting - (default "%Y-%m-%dT%H:%M:%S.000") - --public-key=PUBLIC-KEY A PGP public key that Fastly will use to - encrypt your log files before writing them to - disk - --compression-codec=COMPRESSION-CODEC - The codec used for compression of your logs. - Valid values are zstd, snappy, and gzip. If the - specified codec is "gzip", gzip_level will - default to 3. To specify a different level, - leave compression_codec blank and explicitly - set the level using gzip_level. Specifying both - compression_codec and gzip_level in the same - API request will result in an error. - - logging cloudfiles delete --version=VERSION --name=NAME [] - Delete a Cloudfiles logging endpoint on a Fastly service version - - --version=VERSION Number of service version - -n, --name=NAME The name of the Cloudfiles logging object - -s, --service-id=SERVICE-ID Service ID - - logging digitalocean create --name=NAME --version=VERSION --bucket=BUCKET --access-key=ACCESS-KEY --secret-key=SECRET-KEY [] - Create a DigitalOcean Spaces logging endpoint on a Fastly service version - - -n, --name=NAME The name of the DigitalOcean Spaces logging - object. Used as a primary key for API access - --version=VERSION Number of service version - --bucket=BUCKET The name of the DigitalOcean Space - --access-key=ACCESS-KEY Your DigitalOcean Spaces account access key - --secret-key=SECRET-KEY Your DigitalOcean Spaces account secret key - -s, --service-id=SERVICE-ID Service ID - --domain=DOMAIN The domain of the DigitalOcean Spaces endpoint - (default 'nyc3.digitaloceanspaces.com') - --path=PATH The path to upload logs to - --period=PERIOD How frequently log files are finalized so they - can be available for reading (in seconds, - default 3600) - --gzip-level=GZIP-LEVEL What level of GZIP encoding to have when - dumping logs (default 0, no compression) - --format=FORMAT Apache style log formatting - --message-type=MESSAGE-TYPE - How the message should be formatted. One of: - classic (default), loggly, logplex or blank - --format-version=FORMAT-VERSION - The version of the custom logging format used - for the configured endpoint. Can be either 2 - (default) or 1 - --response-condition=RESPONSE-CONDITION - The name of an existing condition in the - configured endpoint, or leave blank to always - execute - --timestamp-format=TIMESTAMP-FORMAT - strftime specified timestamp formatting - (default "%Y-%m-%dT%H:%M:%S.000") - --placement=PLACEMENT Where in the generated VCL the logging call - should be placed, overriding any format_version - default. Can be none or waf_debug - --public-key=PUBLIC-KEY A PGP public key that Fastly will use to - encrypt your log files before writing them to - disk - --compression-codec=COMPRESSION-CODEC - The codec used for compression of your logs. - Valid values are zstd, snappy, and gzip. If the - specified codec is "gzip", gzip_level will - default to 3. To specify a different level, - leave compression_codec blank and explicitly - set the level using gzip_level. Specifying both - compression_codec and gzip_level in the same - API request will result in an error. - - logging digitalocean list --version=VERSION [] - List DigitalOcean Spaces logging endpoints on a Fastly service version - - -s, --service-id=SERVICE-ID Service ID - --version=VERSION Number of service version - - logging digitalocean describe --version=VERSION --name=NAME [] - Show detailed information about a DigitalOcean Spaces logging endpoint on a - Fastly service version - - -s, --service-id=SERVICE-ID Service ID - --version=VERSION Number of service version - -n, --name=NAME The name of the DigitalOcean Spaces logging - object - - logging digitalocean update --version=VERSION --name=NAME [] - Update a DigitalOcean Spaces logging endpoint on a Fastly service version - - --version=VERSION Number of service version - -n, --name=NAME The name of the DigitalOcean Spaces logging - object - -s, --service-id=SERVICE-ID Service ID - --new-name=NEW-NAME New name of the DigitalOcean Spaces logging - object - --bucket=BUCKET The name of the DigitalOcean Space - --domain=DOMAIN The domain of the DigitalOcean Spaces endpoint - (default 'nyc3.digitaloceanspaces.com') - --access-key=ACCESS-KEY Your DigitalOcean Spaces account access key - --secret-key=SECRET-KEY Your DigitalOcean Spaces account secret key - --path=PATH The path to upload logs to - --period=PERIOD How frequently log files are finalized so they - can be available for reading (in seconds, - default 3600) - --gzip-level=GZIP-LEVEL What level of GZIP encoding to have when - dumping logs (default 0, no compression) - --format=FORMAT Apache style log formatting - --format-version=FORMAT-VERSION - The version of the custom logging format used - for the configured endpoint. Can be either 2 - (default) or 1 - --response-condition=RESPONSE-CONDITION - The name of an existing condition in the - configured endpoint, or leave blank to always - execute - --message-type=MESSAGE-TYPE - How the message should be formatted. One of: - classic (default), loggly, logplex or blank - --timestamp-format=TIMESTAMP-FORMAT - strftime specified timestamp formatting - (default "%Y-%m-%dT%H:%M:%S.000") - --placement=PLACEMENT Where in the generated VCL the logging call - should be placed, overriding any format_version - default. Can be none or waf_debug - --public-key=PUBLIC-KEY A PGP public key that Fastly will use to - encrypt your log files before writing them to - disk - --compression-codec=COMPRESSION-CODEC - The codec used for compression of your logs. - Valid values are zstd, snappy, and gzip. If the - specified codec is "gzip", gzip_level will - default to 3. To specify a different level, - leave compression_codec blank and explicitly - set the level using gzip_level. Specifying both - compression_codec and gzip_level in the same - API request will result in an error. - - logging digitalocean delete --version=VERSION --name=NAME [] - Delete a DigitalOcean Spaces logging endpoint on a Fastly service version - - --version=VERSION Number of service version - -n, --name=NAME The name of the DigitalOcean Spaces logging - object - -s, --service-id=SERVICE-ID Service ID - - logging elasticsearch create --name=NAME --version=VERSION --index=INDEX --url=URL [] - Create an Elasticsearch logging endpoint on a Fastly service version - - -n, --name=NAME The name of the Elasticsearch logging object. - Used as a primary key for API access - --version=VERSION Number of service version - --index=INDEX The name of the Elasticsearch index to send - documents (logs) to. The index must follow - the Elasticsearch index format rules - (https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-create-index.html). - We support strftime - (http://man7.org/linux/man-pages/man3/strftime.3.html) - interpolated variables inside braces prefixed - with a pound symbol. For example, #{%F} will - interpolate as YYYY-MM-DD with today's date - --url=URL The URL to stream logs to. Must use HTTPS. - -s, --service-id=SERVICE-ID Service ID - --pipeline=PIPELINE The ID of the Elasticsearch ingest pipeline - to apply pre-process transformations to - before indexing. For example my_pipeline_id. - Learn more about creating a pipeline in the - Elasticsearch docs - (https://www.elastic.co/guide/en/elasticsearch/reference/current/ingest.html) - --tls-ca-cert=TLS-CA-CERT A secure certificate to authenticate the - server with. Must be in PEM format - --tls-client-cert=TLS-CLIENT-CERT - The client certificate used to make - authenticated requests. Must be in PEM format - --tls-client-key=TLS-CLIENT-KEY - The client private key used to make - authenticated requests. Must be in PEM format - --tls-hostname=TLS-HOSTNAME - The hostname used to verify the server's - certificate. It can either be the Common Name - or a Subject Alternative Name (SAN) - --format=FORMAT Apache style log formatting. Your log must - produce valid JSON that Elasticsearch can - ingest - --format-version=FORMAT-VERSION - The version of the custom logging format used - for the configured endpoint. Can be either 2 - (default) or 1 - --placement=PLACEMENT Where in the generated VCL the logging call - should be placed, overriding any - format_version default. Can be none or - waf_debug - --response-condition=RESPONSE-CONDITION - The name of an existing condition in the - configured endpoint, or leave blank to always - execute - --request-max-entries=REQUEST-MAX-ENTRIES - Maximum number of logs to append to a batch, - if non-zero. Defaults to 0 for unbounded - --request-max-bytes=REQUEST-MAX-BYTES - Maximum size of log batch, if non-zero. - Defaults to 0 for unbounded - - logging elasticsearch list --version=VERSION [] - List Elasticsearch endpoints on a Fastly service version - - -s, --service-id=SERVICE-ID Service ID - --version=VERSION Number of service version - - logging elasticsearch describe --version=VERSION --name=NAME [] - Show detailed information about an Elasticsearch logging endpoint on a - Fastly service version - - -s, --service-id=SERVICE-ID Service ID - --version=VERSION Number of service version - -n, --name=NAME The name of the Elasticsearch logging object - - logging elasticsearch update --version=VERSION --name=NAME [] - Update an Elasticsearch logging endpoint on a Fastly service version - - --version=VERSION Number of service version - -n, --name=NAME The name of the Elasticsearch logging object - -s, --service-id=SERVICE-ID Service ID - --new-name=NEW-NAME New name of the Elasticsearch logging object - --index=INDEX The name of the Elasticsearch index to send - documents (logs) to. The index must follow - the Elasticsearch index format rules - (https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-create-index.html). - We support strftime - (http://man7.org/linux/man-pages/man3/strftime.3.html) - interpolated variables inside braces prefixed - with a pound symbol. For example, #{%F} will - interpolate as YYYY-MM-DD with today's date - --url=URL The URL to stream logs to. Must use HTTPS. - --pipeline=PIPELINE The ID of the Elasticsearch ingest pipeline - to apply pre-process transformations to - before indexing. For example my_pipeline_id. - Learn more about creating a pipeline in the - Elasticsearch docs - (https://www.elastic.co/guide/en/elasticsearch/reference/current/ingest.html) - --tls-ca-cert=TLS-CA-CERT A secure certificate to authenticate the - server with. Must be in PEM format - --tls-client-cert=TLS-CLIENT-CERT - The client certificate used to make - authenticated requests. Must be in PEM format - --tls-client-key=TLS-CLIENT-KEY - The client private key used to make - authenticated requests. Must be in PEM format - --tls-hostname=TLS-HOSTNAME - The hostname used to verify the server's - certificate. It can either be the Common Name - or a Subject Alternative Name (SAN) - --format=FORMAT Apache style log formatting. Your log must - produce valid JSON that Elasticsearch can - ingest - --format-version=FORMAT-VERSION - The version of the custom logging format used - for the configured endpoint. Can be either 2 - (default) or 1 - --placement=PLACEMENT Where in the generated VCL the logging call - should be placed, overriding any - format_version default. Can be none or - waf_debug - --response-condition=RESPONSE-CONDITION - The name of an existing condition in the - configured endpoint, or leave blank to always - execute - --request-max-entries=REQUEST-MAX-ENTRIES - Maximum number of logs to append to a batch, - if non-zero. Defaults to 0 for unbounded - --request-max-bytes=REQUEST-MAX-BYTES - Maximum size of log batch, if non-zero. - Defaults to 0 for unbounded - - logging elasticsearch delete --version=VERSION --name=NAME [] - Delete an Elasticsearch logging endpoint on a Fastly service version - - --version=VERSION Number of service version - -n, --name=NAME The name of the Elasticsearch logging object - -s, --service-id=SERVICE-ID Service ID - - logging azureblob create --name=NAME --version=VERSION --container=CONTAINER --account-name=ACCOUNT-NAME --sas-token=SAS-TOKEN [] - Create an Azure Blob Storage logging endpoint on a Fastly service version - - -n, --name=NAME The name of the Azure Blob Storage logging - object. Used as a primary key for API access - --version=VERSION Number of service version - --container=CONTAINER The name of the Azure Blob Storage container in - which to store logs - --account-name=ACCOUNT-NAME - The unique Azure Blob Storage namespace in - which your data objects are stored - --sas-token=SAS-TOKEN The Azure shared access signature providing - write access to the blob service objects. Be - sure to update your token before it expires or - the logging functionality will not work - -s, --service-id=SERVICE-ID Service ID - --path=PATH The path to upload logs to - --period=PERIOD How frequently log files are finalized so they - can be available for reading (in seconds, - default 3600) - --gzip-level=GZIP-LEVEL What level of GZIP encoding to have when - dumping logs (default 0, no compression) - --format=FORMAT Apache style log formatting - --message-type=MESSAGE-TYPE - How the message should be formatted. One of: - classic (default), loggly, logplex or blank - --format-version=FORMAT-VERSION - The version of the custom logging format used - for the configured endpoint. Can be either 2 - (default) or 1 - --response-condition=RESPONSE-CONDITION - The name of an existing condition in the - configured endpoint, or leave blank to always - execute - --timestamp-format=TIMESTAMP-FORMAT - strftime specified timestamp formatting - (default "%Y-%m-%dT%H:%M:%S.000") - --placement=PLACEMENT Where in the generated VCL the logging call - should be placed, overriding any format_version - default. Can be none or waf_debug - --public-key=PUBLIC-KEY A PGP public key that Fastly will use to - encrypt your log files before writing them to - disk - --file-max-bytes=FILE-MAX-BYTES - The maximum size of a log file in bytes - --compression-codec=COMPRESSION-CODEC - The codec used for compression of your logs. - Valid values are zstd, snappy, and gzip. If the - specified codec is "gzip", gzip_level will - default to 3. To specify a different level, - leave compression_codec blank and explicitly - set the level using gzip_level. Specifying both - compression_codec and gzip_level in the same - API request will result in an error. - - logging azureblob list --version=VERSION [] - List Azure Blob Storage logging endpoints on a Fastly service version - - -s, --service-id=SERVICE-ID Service ID - --version=VERSION Number of service version - - logging azureblob describe --version=VERSION --name=NAME [] - Show detailed information about an Azure Blob Storage logging endpoint on a - Fastly service version - - -s, --service-id=SERVICE-ID Service ID - --version=VERSION Number of service version - -n, --name=NAME The name of the Azure Blob Storage logging - object - - logging azureblob update --version=VERSION --name=NAME [] - Update an Azure Blob Storage logging endpoint on a Fastly service version - - --version=VERSION Number of service version - -n, --name=NAME The name of the Azure Blob Storage logging - object - -s, --service-id=SERVICE-ID Service ID - --new-name=NEW-NAME New name of the Azure Blob Storage logging - object - --container=CONTAINER The name of the Azure Blob Storage container in - which to store logs - --account-name=ACCOUNT-NAME - The unique Azure Blob Storage namespace in - which your data objects are stored - --sas-token=SAS-TOKEN The Azure shared access signature providing - write access to the blob service objects. Be - sure to update your token before it expires or - the logging functionality will not work - --path=PATH The path to upload logs to - --period=PERIOD How frequently log files are finalized so they - can be available for reading (in seconds, - default 3600) - --gzip-level=GZIP-LEVEL What level of GZIP encoding to have when - dumping logs (default 0, no compression) - --format=FORMAT Apache style log formatting - --message-type=MESSAGE-TYPE - How the message should be formatted. One of: - classic (default), loggly, logplex or blank - --format-version=FORMAT-VERSION - The version of the custom logging format used - for the configured endpoint. Can be either 2 - (default) or 1 - --response-condition=RESPONSE-CONDITION - The name of an existing condition in the - configured endpoint, or leave blank to always - execute - --timestamp-format=TIMESTAMP-FORMAT - strftime specified timestamp formatting - (default "%Y-%m-%dT%H:%M:%S.000") - --placement=PLACEMENT Where in the generated VCL the logging call - should be placed, overriding any format_version - default. Can be none or waf_debug - --public-key=PUBLIC-KEY A PGP public key that Fastly will use to - encrypt your log files before writing them to - disk - --file-max-bytes=FILE-MAX-BYTES - The maximum size of a log file in bytes - --compression-codec=COMPRESSION-CODEC - The codec used for compression of your logs. - Valid values are zstd, snappy, and gzip. If the - specified codec is "gzip", gzip_level will - default to 3. To specify a different level, - leave compression_codec blank and explicitly - set the level using gzip_level. Specifying both - compression_codec and gzip_level in the same - API request will result in an error. - - logging azureblob delete --version=VERSION --name=NAME [] - Delete an Azure Blob Storage logging endpoint on a Fastly service version - - --version=VERSION Number of service version - -n, --name=NAME The name of the Azure Blob Storage logging - object - -s, --service-id=SERVICE-ID Service ID - - logging datadog create --name=NAME --version=VERSION --auth-token=AUTH-TOKEN [] - Create a Datadog logging endpoint on a Fastly service version - - -n, --name=NAME The name of the Datadog logging object. Used as - a primary key for API access - --version=VERSION Number of service version - --auth-token=AUTH-TOKEN The API key from your Datadog account - -s, --service-id=SERVICE-ID Service ID - --region=REGION The region that log data will be sent to. One - of US or EU. Defaults to US if undefined - --format=FORMAT Apache style log formatting. For details on the - default value refer to the documentation - (https://developer.fastly.com/reference/api/logging/datadog/) - --format-version=FORMAT-VERSION - The version of the custom logging format used - for the configured endpoint. Can be either 2 - (default) or 1 - --response-condition=RESPONSE-CONDITION - The name of an existing condition in the - configured endpoint, or leave blank to always - execute - --placement=PLACEMENT Where in the generated VCL the logging call - should be placed, overriding any format_version - default. Can be none or waf_debug - - logging datadog list --version=VERSION [] - List Datadog endpoints on a Fastly service version - - -s, --service-id=SERVICE-ID Service ID - --version=VERSION Number of service version - - logging datadog describe --version=VERSION --name=NAME [] - Show detailed information about a Datadog logging endpoint on a Fastly - service version - - -s, --service-id=SERVICE-ID Service ID - --version=VERSION Number of service version - -n, --name=NAME The name of the Datadog logging object - - logging datadog update --version=VERSION --name=NAME [] - Update a Datadog logging endpoint on a Fastly service version - - --version=VERSION Number of service version - -n, --name=NAME The name of the Datadog logging object - -s, --service-id=SERVICE-ID Service ID - --new-name=NEW-NAME New name of the Datadog logging object - --auth-token=AUTH-TOKEN The API key from your Datadog account - --region=REGION The region that log data will be sent to. One - of US or EU. Defaults to US if undefined - --format=FORMAT Apache style log formatting. For details on the - default value refer to the documentation - (https://developer.fastly.com/reference/api/logging/datadog/) - --format-version=FORMAT-VERSION - The version of the custom logging format used - for the configured endpoint. Can be either 2 - (default) or 1 - --response-condition=RESPONSE-CONDITION - The name of an existing condition in the - configured endpoint, or leave blank to always - execute - --placement=PLACEMENT Where in the generated VCL the logging call - should be placed, overriding any format_version - default. Can be none or waf_debug - - logging datadog delete --version=VERSION --name=NAME [] - Delete a Datadog logging endpoint on a Fastly service version - - --version=VERSION Number of service version - -n, --name=NAME The name of the Datadog logging object - -s, --service-id=SERVICE-ID Service ID - - logging https create --name=NAME --version=VERSION --url=URL [] - Create an HTTPS logging endpoint on a Fastly service version - - -n, --name=NAME The name of the HTTPS logging object. Used as - a primary key for API access - --version=VERSION Number of service version - --url=URL URL that log data will be sent to. Must use - the https protocol - -s, --service-id=SERVICE-ID Service ID - --content-type=CONTENT-TYPE - Content type of the header sent with the - request - --header-name=HEADER-NAME Name of the custom header sent with the - request - --header-value=HEADER-VALUE - Value of the custom header sent with the - request - --method=METHOD HTTP method used for request. Can be POST or - PUT. Defaults to POST if not specified - --json-format=JSON-FORMAT Enforces valid JSON formatting for log - entries. Can be disabled 0, array of json - (wraps JSON log batches in an array) 1, or - newline delimited json (places each JSON log - entry onto a new line in a batch) 2 - --tls-ca-cert=TLS-CA-CERT A secure certificate to authenticate the - server with. Must be in PEM format - --tls-client-cert=TLS-CLIENT-CERT - The client certificate used to make - authenticated requests. Must be in PEM format - --tls-client-key=TLS-CLIENT-KEY - The client private key used to make - authenticated requests. Must be in PEM format - --tls-hostname=TLS-HOSTNAME - The hostname used to verify the server's - certificate. It can either be the Common Name - or a Subject Alternative Name (SAN) - --message-type=MESSAGE-TYPE - How the message should be formatted. One of: - classic (default), loggly, logplex or blank - --format=FORMAT Apache style log formatting. Your log must - produce valid JSON that HTTPS can ingest - --format-version=FORMAT-VERSION - The version of the custom logging format used - for the configured endpoint. Can be either 2 - (default) or 1 - --placement=PLACEMENT Where in the generated VCL the logging call - should be placed, overriding any - format_version default. Can be none or - waf_debug - --response-condition=RESPONSE-CONDITION - The name of an existing condition in the - configured endpoint, or leave blank to always - execute - --request-max-entries=REQUEST-MAX-ENTRIES - Maximum number of logs to append to a batch, - if non-zero. Defaults to 0 for unbounded - --request-max-bytes=REQUEST-MAX-BYTES - Maximum size of log batch, if non-zero. - Defaults to 0 for unbounded - - logging https list --version=VERSION [] - List HTTPS endpoints on a Fastly service version - - -s, --service-id=SERVICE-ID Service ID - --version=VERSION Number of service version - - logging https describe --version=VERSION --name=NAME [] - Show detailed information about an HTTPS logging endpoint on a Fastly - service version - - -s, --service-id=SERVICE-ID Service ID - --version=VERSION Number of service version - -n, --name=NAME The name of the HTTPS logging object - - logging https update --version=VERSION --name=NAME [] - Update an HTTPS logging endpoint on a Fastly service version - - --version=VERSION Number of service version - -n, --name=NAME The name of the HTTPS logging object - -s, --service-id=SERVICE-ID Service ID - --new-name=NEW-NAME New name of the HTTPS logging object - --url=URL URL that log data will be sent to. Must use - the https protocol - --content-type=CONTENT-TYPE - Content type of the header sent with the - request - --header-name=HEADER-NAME Name of the custom header sent with the - request - --header-value=HEADER-VALUE - Value of the custom header sent with the - request - --method=METHOD HTTP method used for request. Can be POST or - PUT. Defaults to POST if not specified - --json-format=JSON-FORMAT Enforces valid JSON formatting for log - entries. Can be disabled 0, array of json - (wraps JSON log batches in an array) 1, or - newline delimited json (places each JSON log - entry onto a new line in a batch) 2 - --tls-ca-cert=TLS-CA-CERT A secure certificate to authenticate the - server with. Must be in PEM format - --tls-client-cert=TLS-CLIENT-CERT - The client certificate used to make - authenticated requests. Must be in PEM format - --tls-client-key=TLS-CLIENT-KEY - The client private key used to make - authenticated requests. Must be in PEM format - --tls-hostname=TLS-HOSTNAME - The hostname used to verify the server's - certificate. It can either be the Common Name - or a Subject Alternative Name (SAN) - --message-type=MESSAGE-TYPE - How the message should be formatted. One of: - classic (default), loggly, logplex or blank - --format=FORMAT Apache style log formatting. Your log must - produce valid JSON that HTTPS can ingest - --format-version=FORMAT-VERSION - The version of the custom logging format used - for the configured endpoint. Can be either 2 - (default) or 1 - --placement=PLACEMENT Where in the generated VCL the logging call - should be placed, overriding any - format_version default. Can be none or - waf_debug - --response-condition=RESPONSE-CONDITION - The name of an existing condition in the - configured endpoint, or leave blank to always - execute - --request-max-entries=REQUEST-MAX-ENTRIES - Maximum number of logs to append to a batch, - if non-zero. Defaults to 0 for unbounded - --request-max-bytes=REQUEST-MAX-BYTES - Maximum size of log batch, if non-zero. - Defaults to 0 for unbounded - - logging https delete --version=VERSION --name=NAME [] - Delete an HTTPS logging endpoint on a Fastly service version - - --version=VERSION Number of service version - -n, --name=NAME The name of the HTTPS logging object - -s, --service-id=SERVICE-ID Service ID - - logging kafka create --name=NAME --version=VERSION --topic=TOPIC --brokers=BROKERS [] - Create a Kafka logging endpoint on a Fastly service version - - -n, --name=NAME The name of the Kafka logging object. Used as - a primary key for API access - --version=VERSION Number of service version - --topic=TOPIC The Kafka topic to send logs to - --brokers=BROKERS A comma-separated list of IP addresses or - hostnames of Kafka brokers - -s, --service-id=SERVICE-ID Service ID - --compression-codec=COMPRESSION-CODEC - The codec used for compression of your logs. - One of: gzip, snappy, lz4 - --required-acks=REQUIRED-ACKS - The Number of acknowledgements a leader must - receive before a write is considered - successful. One of: 1 (default) One server - needs to respond. 0 No servers need to - respond. -1 Wait for all in-sync replicas to - respond - --use-tls Whether to use TLS for secure logging. Can be - either true or false - --tls-ca-cert=TLS-CA-CERT A secure certificate to authenticate the - server with. Must be in PEM format - --tls-client-cert=TLS-CLIENT-CERT - The client certificate used to make - authenticated requests. Must be in PEM format - --tls-client-key=TLS-CLIENT-KEY - The client private key used to make - authenticated requests. Must be in PEM format - --tls-hostname=TLS-HOSTNAME - The hostname used to verify the server's - certificate. It can either be the Common Name - or a Subject Alternative Name (SAN) - --format=FORMAT Apache style log formatting. Your log must - produce valid JSON that Kafka can ingest - --format-version=FORMAT-VERSION - The version of the custom logging format used - for the configured endpoint. Can be either 2 - (default) or 1 - --placement=PLACEMENT Where in the generated VCL the logging call - should be placed, overriding any - format_version default. Can be none or - waf_debug - --response-condition=RESPONSE-CONDITION - The name of an existing condition in the - configured endpoint, or leave blank to always - execute - --parse-log-keyvals Parse key-value pairs within the log format - --max-batch-size=MAX-BATCH-SIZE - The maximum size of the log batch in bytes - --use-sasl Enable SASL authentication. Requires - --auth-method, --username, and --password to - be specified - --auth-method=AUTH-METHOD SASL authentication method. Valid values are: - plain, scram-sha-256, scram-sha-512 - --username=USERNAME SASL authentication username. Required if - --auth-method is specified - --password=PASSWORD SASL authentication password. Required if - --auth-method is specified - - logging kafka list --version=VERSION [] - List Kafka endpoints on a Fastly service version - - -s, --service-id=SERVICE-ID Service ID - --version=VERSION Number of service version - - logging kafka describe --version=VERSION --name=NAME [] - Show detailed information about a Kafka logging endpoint on a Fastly service - version - - -s, --service-id=SERVICE-ID Service ID - --version=VERSION Number of service version - -n, --name=NAME The name of the Kafka logging object - - logging kafka update --version=VERSION --name=NAME [] - Update a Kafka logging endpoint on a Fastly service version - - --version=VERSION Number of service version - -n, --name=NAME The name of the Kafka logging object - -s, --service-id=SERVICE-ID Service ID - --new-name=NEW-NAME New name of the Kafka logging object - --topic=TOPIC The Kafka topic to send logs to - --brokers=BROKERS A comma-separated list of IP addresses or - hostnames of Kafka brokers - --compression-codec=COMPRESSION-CODEC - The codec used for compression of your logs. - One of: gzip, snappy, lz4 - --required-acks=REQUIRED-ACKS - The Number of acknowledgements a leader must - receive before a write is considered - successful. One of: 1 (default) One server - needs to respond. 0 No servers need to - respond. -1 Wait for all in-sync replicas to - respond - --use-tls Whether to use TLS for secure logging. Can be - either true or false - --tls-ca-cert=TLS-CA-CERT A secure certificate to authenticate the - server with. Must be in PEM format - --tls-client-cert=TLS-CLIENT-CERT - The client certificate used to make - authenticated requests. Must be in PEM format - --tls-client-key=TLS-CLIENT-KEY - The client private key used to make - authenticated requests. Must be in PEM format - --tls-hostname=TLS-HOSTNAME - The hostname used to verify the server's - certificate. It can either be the Common Name - or a Subject Alternative Name (SAN) - --format=FORMAT Apache style log formatting. Your log must - produce valid JSON that Kafka can ingest - --format-version=FORMAT-VERSION - The version of the custom logging format used - for the configured endpoint. Can be either 2 - (default) or 1 - --placement=PLACEMENT Where in the generated VCL the logging call - should be placed, overriding any - format_version default. Can be none or - waf_debug - --response-condition=RESPONSE-CONDITION - The name of an existing condition in the - configured endpoint, or leave blank to always - execute - --[no-]parse-log-keyvals Parse key-value pairs within the log format - --max-batch-size=MAX-BATCH-SIZE - The maximum size of the log batch in bytes - --use-sasl Enable SASL authentication. Requires - --auth-method, --username, and --password to - be specified - --auth-method=AUTH-METHOD SASL authentication method. Valid values are: - plain, scram-sha-256, scram-sha-512 - --username=USERNAME SASL authentication username. Required if - --auth-method is specified - --password=PASSWORD SASL authentication password. Required if - --auth-method is specified - - logging kafka delete --version=VERSION --name=NAME [] - Delete a Kafka logging endpoint on a Fastly service version - - --version=VERSION Number of service version - -n, --name=NAME The name of the Kafka logging object - -s, --service-id=SERVICE-ID Service ID - - logging googlepubsub create --name=NAME --version=VERSION --user=USER --secret-key=SECRET-KEY --topic=TOPIC --project-id=PROJECT-ID [] - Create a Google Cloud Pub/Sub logging endpoint on a Fastly service version - - -n, --name=NAME The name of the Google Cloud Pub/Sub logging - object. Used as a primary key for API access - --version=VERSION Number of service version - --user=USER Your Google Cloud Platform service account - email address. The client_email field in your - service account authentication JSON - --secret-key=SECRET-KEY Your Google Cloud Platform account secret key. - The private_key field in your service account - authentication JSON - --topic=TOPIC The Google Cloud Pub/Sub topic to which logs - will be published - --project-id=PROJECT-ID The ID of your Google Cloud Platform project - -s, --service-id=SERVICE-ID Service ID - --format=FORMAT Apache style log formatting - --format-version=FORMAT-VERSION - The version of the custom logging format used - for the configured endpoint. Can be either 2 - (default) or 1 - --placement=PLACEMENT Where in the generated VCL the logging call - should be placed, overriding any format_version - default. Can be none or waf_debug. This field - is not required and has no default value - --response-condition=RESPONSE-CONDITION - The name of an existing condition in the - configured endpoint, or leave blank to always - execute - - logging googlepubsub list --version=VERSION [] - List Google Cloud Pub/Sub endpoints on a Fastly service version - - -s, --service-id=SERVICE-ID Service ID - --version=VERSION Number of service version - - logging googlepubsub describe --version=VERSION --name=NAME [] - Show detailed information about a Google Cloud Pub/Sub logging endpoint on a - Fastly service version - - -s, --service-id=SERVICE-ID Service ID - --version=VERSION Number of service version - -n, --name=NAME The name of the Google Cloud Pub/Sub logging - object - - logging googlepubsub update --version=VERSION --name=NAME [] - Update a Google Cloud Pub/Sub logging endpoint on a Fastly service version - - --version=VERSION Number of service version - -n, --name=NAME The name of the Google Cloud Pub/Sub logging - object - -s, --service-id=SERVICE-ID Service ID - --new-name=NEW-NAME New name of the Google Cloud Pub/Sub logging - object - --user=USER Your Google Cloud Platform service account - email address. The client_email field in your - service account authentication JSON - --secret-key=SECRET-KEY Your Google Cloud Platform account secret key. - The private_key field in your service account - authentication JSON - --topic=TOPIC The Google Cloud Pub/Sub topic to which logs - will be published - --project-id=PROJECT-ID The ID of your Google Cloud Platform project - --format=FORMAT Apache style log formatting - --format-version=FORMAT-VERSION - The version of the custom logging format used - for the configured endpoint. Can be either 2 - (default) or 1 - --placement=PLACEMENT Where in the generated VCL the logging call - should be placed, overriding any format_version - default. Can be none or waf_debug. This field - is not required and has no default value - --response-condition=RESPONSE-CONDITION - The name of an existing condition in the - configured endpoint, or leave blank to always - execute - - logging googlepubsub delete --version=VERSION --name=NAME [] - Delete a Google Cloud Pub/Sub logging endpoint on a Fastly service version - - --version=VERSION Number of service version - -n, --name=NAME The name of the Google Cloud Pub/Sub logging - object - -s, --service-id=SERVICE-ID Service ID - - logging openstack create --name=NAME --version=VERSION --bucket=BUCKET --access-key=ACCESS-KEY --user=USER --url=URL [] - Create an OpenStack logging endpoint on a Fastly service version - - -n, --name=NAME The name of the OpenStack logging object. Used - as a primary key for API access - --version=VERSION Number of service version - --bucket=BUCKET The name of your OpenStack container - --access-key=ACCESS-KEY Your OpenStack account access key - --user=USER The username for your OpenStack account - --url=URL Your OpenStack auth url - -s, --service-id=SERVICE-ID Service ID - --public-key=PUBLIC-KEY A PGP public key that Fastly will use to - encrypt your log files before writing them to - disk - --path=PATH The path to upload logs to - --period=PERIOD How frequently log files are finalized so they - can be available for reading (in seconds, - default 3600) - --gzip-level=GZIP-LEVEL What level of GZIP encoding to have when - dumping logs (default 0, no compression) - --format=FORMAT Apache style log formatting - --message-type=MESSAGE-TYPE - How the message should be formatted. One of: - classic (default), loggly, logplex or blank - --format-version=FORMAT-VERSION - The version of the custom logging format used - for the configured endpoint. Can be either 2 - (default) or 1 - --response-condition=RESPONSE-CONDITION - The name of an existing condition in the - configured endpoint, or leave blank to always - execute - --timestamp-format=TIMESTAMP-FORMAT - strftime specified timestamp formatting - (default "%Y-%m-%dT%H:%M:%S.000") - --placement=PLACEMENT Where in the generated VCL the logging call - should be placed, overriding any format_version - default. Can be none or waf_debug - --compression-codec=COMPRESSION-CODEC - The codec used for compression of your logs. - Valid values are zstd, snappy, and gzip. If the - specified codec is "gzip", gzip_level will - default to 3. To specify a different level, - leave compression_codec blank and explicitly - set the level using gzip_level. Specifying both - compression_codec and gzip_level in the same - API request will result in an error. - - logging openstack list --version=VERSION [] - List OpenStack logging endpoints on a Fastly service version - - -s, --service-id=SERVICE-ID Service ID - --version=VERSION Number of service version - - logging openstack describe --version=VERSION --name=NAME [] - Show detailed information about an OpenStack logging endpoint on a Fastly - service version - - -s, --service-id=SERVICE-ID Service ID - --version=VERSION Number of service version - -n, --name=NAME The name of the OpenStack logging object - - logging openstack update --version=VERSION --name=NAME [] - Update an OpenStack logging endpoint on a Fastly service version - - --version=VERSION Number of service version - -n, --name=NAME The name of the OpenStack logging object - -s, --service-id=SERVICE-ID Service ID - --new-name=NEW-NAME New name of the OpenStack logging object - --bucket=BUCKET The name of the Openstack Space - --access-key=ACCESS-KEY Your OpenStack account access key - --user=USER The username for your OpenStack account. - --url=URL Your OpenStack auth url. - --path=PATH The path to upload logs to - --period=PERIOD How frequently log files are finalized so they - can be available for reading (in seconds, - default 3600) - --gzip-level=GZIP-LEVEL What level of GZIP encoding to have when - dumping logs (default 0, no compression) - --format=FORMAT Apache style log formatting - --format-version=FORMAT-VERSION - The version of the custom logging format used - for the configured endpoint. Can be either 2 - (default) or 1 - --response-condition=RESPONSE-CONDITION - The name of an existing condition in the - configured endpoint, or leave blank to always - execute - --message-type=MESSAGE-TYPE - How the message should be formatted. One of: - classic (default), loggly, logplex or blank - --timestamp-format=TIMESTAMP-FORMAT - strftime specified timestamp formatting - (default "%Y-%m-%dT%H:%M:%S.000") - --placement=PLACEMENT Where in the generated VCL the logging call - should be placed, overriding any format_version - default. Can be none or waf_debug - --public-key=PUBLIC-KEY A PGP public key that Fastly will use to - encrypt your log files before writing them to - disk - --compression-codec=COMPRESSION-CODEC - The codec used for compression of your logs. - Valid values are zstd, snappy, and gzip. If the - specified codec is "gzip", gzip_level will - default to 3. To specify a different level, - leave compression_codec blank and explicitly - set the level using gzip_level. Specifying both - compression_codec and gzip_level in the same - API request will result in an error. - - logging openstack delete --version=VERSION --name=NAME [] - Delete an OpenStack logging endpoint on a Fastly service version - - --version=VERSION Number of service version - -n, --name=NAME The name of the OpenStack logging object - -s, --service-id=SERVICE-ID Service ID - - logs tail [] - Tail Compute@Edge logs - - -s, --service-id=SERVICE-ID Service ID - --from=FROM From time, in unix seconds - --to=TO To time, in unix seconds - --sort-buffer=1s Sort buffer is how long to buffer logs, - attempting to sort them before printing, - defaults to 1s (second) - --search-padding=2s Search padding is how much of a window on - either side of From and To to use for - searching, defaults to 2s (seconds) - --stream=STREAM Stream specifies which of 'stdout' or 'stderr' - to output, defaults to undefined (all streams) - - stats regions - List stats regions - - - stats historical --service-id=SERVICE-ID [] - View historical stats for a Fastly service - - -s, --service-id=SERVICE-ID Service ID - --from=FROM From time, accepted formats at - https://docs.fastly.com/api/stats#Range - --to=TO To time - --by=BY Aggregation period (minute/hour/day) - --region=REGION Filter by region ('stats regions' to list) - --format=FORMAT Output format (json) - - stats realtime --service-id=SERVICE-ID [] - View realtime stats for a Fastly service - - -s, --service-id=SERVICE-ID Service ID - --format=FORMAT Output format (json) - -For help on a specific command, try e.g. - - fastly help configure - fastly configure --help - - -`) + "\n\n" diff --git a/pkg/app/usage.go b/pkg/app/usage.go index 451978e99..4293545f1 100644 --- a/pkg/app/usage.go +++ b/pkg/app/usage.go @@ -2,150 +2,40 @@ package app import ( "bytes" + _ "embed" "encoding/json" + "errors" + "fmt" "io" + "os" "strings" "text/template" - "github.com/fastly/cli/pkg/text" "github.com/fastly/kingpin" -) - -type usageJSON struct { - GlobalFlags []flagJSON `json:"globalFlags"` - Commands []commandJSON `json:"commands"` -} - -type flagJSON struct { - Name string `json:"name"` - Description string `json:"description"` - Placeholder string `json:"placeholder"` - Required bool `json:"required"` - Default string `json:"default"` - IsBool bool `json:"isBool"` -} - -type commandJSON struct { - Name string `json:"name"` - Description string `json:"description"` - Flags []flagJSON `json:"flags"` - Children []commandJSON `json:"children"` -} - -func getFlagJSON(models []*kingpin.ClauseModel) []flagJSON { - var flags []flagJSON - for _, f := range models { - var flag flagJSON - flag.Name = f.Name - flag.Description = f.Help - flag.Placeholder = f.PlaceHolder - flag.Required = f.Required - flag.Default = strings.Join(f.Default, ",") - flag.IsBool = f.IsBoolFlag() - flags = append(flags, flag) - } - return flags -} - -func getGlobalFlagJSON(models []*kingpin.ClauseModel) []flagJSON { - var globalFlags []*kingpin.ClauseModel - for _, f := range models { - if !f.Hidden { - globalFlags = append(globalFlags, f) - } - } - return getFlagJSON(globalFlags) -} - -func getCommandJSON(models []*kingpin.CmdModel) []commandJSON { - var commands []commandJSON - for _, c := range models { - var cmd commandJSON - cmd.Name = c.Name - cmd.Description = c.Help - cmd.Flags = getFlagJSON(c.Flags) - cmd.Children = getCommandJSON(c.Commands) - commands = append(commands, cmd) - } - return commands -} - -// UsageJSON returns a structured representation of the application usage -// documentation in JSON format. This is useful for machine consumtion. -func UsageJSON(app *kingpin.Application) (string, error) { - usage := &usageJSON{ - GlobalFlags: getGlobalFlagJSON(app.Model().Flags), - Commands: getCommandJSON(app.Model().Commands), - } - j, err := json.Marshal(usage) - if err != nil { - return "", err - } - - return string(j), nil -} + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) // Usage returns a contextual usage string for the application. In order to deal // with Kingpin's annoying love of side effects, we have to swap the app.Writers // to capture output; the out and err parameters, therefore, are the io.Writers // re-assigned to the app via app.Writers after calling Usage. -func Usage(args []string, app *kingpin.Application, out, err io.Writer) string { +func Usage(args []string, app *kingpin.Application, out, err io.Writer, vars map[string]any) string { var buf bytes.Buffer app.Writers(&buf, io.Discard) app.UsageContext(&kingpin.UsageContext{ Template: CompactUsageTemplate, Funcs: UsageTemplateFuncs, + Vars: vars, }) app.Usage(args) app.Writers(out, err) return buf.String() } -// WARNING: kingpin has no way of decorating flags as being "global" therefore -// if you add/remove a global flag you will also need to update flag binding in -// pkg/app/app.go. -var globalFlags = map[string]bool{ - "help": true, - "token": true, - "verbose": true, -} - -// UsageTemplateFuncs is a map of template functions which get passed to the -// usage template renderer. -var UsageTemplateFuncs = template.FuncMap{ - "CommandsToTwoColumns": func(c []*kingpin.CmdModel) [][2]string { - rows := [][2]string{} - for _, cmd := range c { - if !cmd.Hidden { - rows = append(rows, [2]string{cmd.Name, cmd.Help}) - } - } - return rows - }, - "GlobalFlags": func(f []*kingpin.ClauseModel) []*kingpin.ClauseModel { - flags := []*kingpin.ClauseModel{} - for _, flag := range f { - if globalFlags[flag.Name] { - flags = append(flags, flag) - } - } - return flags - }, - "OptionalFlags": func(f []*kingpin.ClauseModel) []*kingpin.ClauseModel { - optionalFlags := []*kingpin.ClauseModel{} - for _, flag := range f { - if !flag.Required && !flag.Hidden && !globalFlags[flag.Name] { - optionalFlags = append(optionalFlags, flag) - } - } - return optionalFlags - }, - "Bold": func(s string) string { - return text.Bold(s) - }, -} - // CompactUsageTemplate is the default usage template, rendered when users type // e.g. just `fastly`, or use the `-h, --help` flag. var CompactUsageTemplate = `{{define "FormatCommand" -}} @@ -202,8 +92,73 @@ var CompactUsageTemplate = `{{define "FormatCommand" -}} {{T "COMMANDS"|Bold}} {{.App.Commands|CommandsToTwoColumns|FormatTwoColumns}} {{end -}} +{{T "SEE ALSO"|Bold}} +{{.Context.SelectedCommand|SeeAlso}} ` +// UsageTemplateFuncs is a map of template functions which get passed to the +// usage template renderer. +var UsageTemplateFuncs = template.FuncMap{ + "CommandsToTwoColumns": func(c []*kingpin.CmdModel) [][2]string { + rows := [][2]string{} + for _, cmd := range c { + if !cmd.Hidden { + rows = append(rows, [2]string{cmd.Name, cmd.Help}) + } + } + return rows + }, + "GlobalFlags": func(f []*kingpin.ClauseModel) []*kingpin.ClauseModel { + flags := []*kingpin.ClauseModel{} + for _, flag := range f { + if globalFlags[flag.Name] { + flags = append(flags, flag) + } + } + return flags + }, + "OptionalFlags": func(f []*kingpin.ClauseModel) []*kingpin.ClauseModel { + optionalFlags := []*kingpin.ClauseModel{} + for _, flag := range f { + if !flag.Required && !flag.Hidden && !globalFlags[flag.Name] { + optionalFlags = append(optionalFlags, flag) + } + } + return optionalFlags + }, + "Bold": func(s string) string { + return text.Bold(s) + }, + "SeeAlso": func(cm *kingpin.CmdModel) string { + cmd := cm.FullCommand() + url := "https://www.fastly.com/documentation/reference/cli/" + var trail string + if len(cmd) > 0 { + trail = "/" + } + return fmt.Sprintf(" %s%s%s", url, strings.ReplaceAll(cmd, " ", "/"), trail) + }, +} + +// IMPORTANT: Kingpin doesn't support global flags. +// We hack a solution in ./run.go (`configureKingpin` function). +// +// NOTE: This map is used to help populate the CLI 'usage' template renderer. +var globalFlags = map[string]bool{ + "accept-defaults": true, + "account": true, + "auto-yes": true, + "debug-mode": true, + "enable-sso": true, + "endpoint": true, + "help": true, + "non-interactive": true, + "profile": true, + "quiet": true, + "token": true, + "verbose": true, +} + // VerboseUsageTemplate is the full-fat usage template, rendered when users type // the long-form e.g. `fastly help service`. const VerboseUsageTemplate = `{{define "FormatCommands" -}} @@ -247,4 +202,412 @@ const VerboseUsageTemplate = `{{define "FormatCommands" -}} {{T "COMMANDS"|Bold -}} {{template "FormatCommands" .App}} {{end -}} +{{T "SEE ALSO"|Bold}} +{{.Context.SelectedCommand|SeeAlso}} ` + +// processCommandInput groups together all the logic related to parsing and +// processing the incoming command request from the user, as well as handling +// the various places where help output can be displayed. +func processCommandInput( + data *global.Data, + app *kingpin.Application, + commands []argparser.Command, +) (command argparser.Command, cmdName string, err error) { + // As the `help` command model gets privately added as a side-effect of + // kingpin.Parse, we cannot add the `--format json` flag to the model. + // Therefore, we have to manually parse the args slice here to check for the + // existence of `help --format json`, if present we print usage JSON and + // exit early. + if argparser.ArgsIsHelpJSON(data.Args) { + j, err := UsageJSON(app) + if err != nil { + data.ErrLog.Add(err) + return command, cmdName, err + } + fmt.Fprintf(data.Output, "%s", j) + return command, strings.Join(data.Args, ""), nil + } + + // Use partial application to generate help output function. + help := displayHelp(data.ErrLog, data.Args, app, data.Output, io.Discard) + + // Handle parse errors and display contextual usage if possible. Due to bugs + // and an obsession for lots of output side-effects in the kingpin.Parse + // logic, we suppress it from writing any usage or errors to the writer by + // swapping the writer with a no-op and then restoring the real writer + // afterwards. This ensures usage text is only written once to the writer + // and gives us greater control over our error formatting. + app.Writers(io.Discard, io.Discard) + + // The `vars` variable is passed into our CLI's Usage() function and exposes + // variables to the template used to generate help output. + // + // NOTE: The zero value of a map is nil. + // A nil map has no keys, nor can keys be added until initialised. + // + // TODO: In the future expose some variables for the template to utilise. + // We don't initialise the map currently as there are no variables to expose. + // But it's useful to have it implemented so it's ready to roll when we do. + var vars map[string]any + + if argparser.IsVerboseAndQuiet(data.Args) { + return command, cmdName, fsterr.RemediationError{ + Inner: errors.New("--verbose and --quiet flag provided"), + Remediation: "Either remove both --verbose and --quiet flags, or one of them.", + } + } + + if argparser.IsHelpFlagOnly(data.Args) && len(data.Args) == 1 { + return command, cmdName, fsterr.SkipExitError{ + Skip: true, + Err: help(vars, nil), + } + } + + // NOTE: We call two similar methods below: ParseContext() and Parse(). + // + // We call Parse() because we want the high-level side effect of processing + // the command information, but we call ParseContext() because we require a + // context object separately to identify if the --help flag was passed (this + // isn't possible to do with the Parse() method). + // + // Internally Parse() calls ParseContext(), to help it handle specific + // behaviours such as configuring pre and post conditional behaviours, as well + // as other related settings. + // + // Normally this would mean Parse() could fail because ParseContext() failed, + // which happens if the given command or one of its sub commands are + // unrecognised or if an unrecognised flag is provided, while Parse() can also + // fail if a 'required' flag is missing. But in reality, because we call + // ParseContext() first, it means the Parse() function should only really + // error on things not already caught by ParseContext(). + // + // ctx.SelectedCommand will be nil if only a flag like --verbose or -v is + // provided but with no actual command set so we check with IsGlobalFlagsOnly. + noargs := len(data.Args) == 0 + globalFlagsOnly := argparser.IsGlobalFlagsOnly(data.Args) + ctx, err := app.ParseContext(data.Args) + if err != nil && !argparser.IsCompletion(data.Args) || noargs || globalFlagsOnly { + if noargs || globalFlagsOnly { + err = fmt.Errorf("command not specified") + } + return command, cmdName, help(vars, err) + } + + if len(data.Args) == 1 && data.Args[0] == "--" { + return command, cmdName, fsterr.RemediationError{ + Inner: errors.New("-- is invalid input when not followed by a positional argument"), + Remediation: "If looking for help output try: `fastly help` for full command list or `fastly --help` for command summary.", + } + } + + // NOTE: `fastly help`, no flags, or only globals, should skip conditional. + // + // This is because the `ctx` variable will be assigned a + // `kingpin.ParseContext` whose `SelectedCommand` will be nil. + // + // Additionally we don't want to use the ctx if dealing with a shell + // completion flag, as that depends on kingpin.Parse() being called, and so + // the `ctx` is otherwise empty. + var found bool + if !noargs && !globalFlagsOnly && !argparser.IsHelpOnly(data.Args) && !argparser.IsHelpFlagOnly(data.Args) && !argparser.IsCompletion(data.Args) && !argparser.IsCompletionScript(data.Args) { + command, found = argparser.Select(ctx.SelectedCommand.FullCommand(), commands) + if !found { + return command, cmdName, help(vars, err) + } + } + + if argparser.ContextHasHelpFlag(ctx) && !argparser.IsHelpFlagOnly(data.Args) { + return command, cmdName, fsterr.SkipExitError{ + Skip: true, + Err: help(vars, nil), + } + } + + // NOTE: app.Parse() resets the default values for app.Writers() from + // io.Discard to os.Stdout and os.Stderr, meaning when using a shell + // autocomplete flag we'll not only see the expected output but also a help + // message because the parser has no matching command and so it thinks there + // is an error and prints the help output for us. + // + // The only way I've found to prevent this is by ensuring the arguments + // provided have a valid command along with the flag, for example: + // + // fastly --completion-script-bash acl + // + // But rather than rely on a feature command, we have defined a hidden + // command that we can safely append to the arguments and not have to worry + // about it getting removed accidentally in the future as we now have a test + // to validate the shell autocomplete behaviours. + // + // Lastly, we don't want to append our hidden shellcomplete command if the + // caller passes --completion-bash because adding a command to the arguments + // list in that scenario would cause Kingpin logic to fail (as it expects the + // flag to be used on its own). + if argparser.IsCompletionScript(data.Args) { + data.Args = append(data.Args, "shellcomplete") + } + + cmdName, err = app.Parse(data.Args) + if err != nil { + return command, "", help(vars, err) + } + + // Restore output writers + app.Writers(data.Output, io.Discard) + + // Kingpin generates shell completion as a side-effect of kingpin.Parse() so + // we allow it to call os.Exit, only if a completion flag is present. + if argparser.IsCompletion(data.Args) || argparser.IsCompletionScript(data.Args) { + app.Terminate(os.Exit) + return command, "shell-autocomplete", nil + } + + // A side-effect of suppressing app.Parse from writing output is the usage + // isn't printed for the default `help` command. Therefore we capture it + // here by calling Parse, again swapping the Writers. This also ensures the + // larger and more verbose help formatting is used. + if cmdName == "help" { + return command, cmdName, fsterr.SkipExitError{ + Skip: true, + Err: fsterr.RemediationError{ + Prefix: useFullHelpOutput(app, data.Args, data.Output).String(), + }, + } + } + + // Catch scenario where user wants to view help with the following format: + // fastly --help + if argparser.IsHelpFlagOnly(data.Args) { + return command, cmdName, fsterr.SkipExitError{ + Skip: true, + Err: help(vars, nil), + } + } + + return command, cmdName, nil +} + +func useFullHelpOutput(app *kingpin.Application, args []string, out io.Writer) *bytes.Buffer { + var buf bytes.Buffer + app.Writers(&buf, io.Discard) + _, _ = app.Parse(args) + app.Writers(out, io.Discard) + + // The full-fat output of `fastly help` should have a hint at the bottom + // for more specific help. Unfortunately I don't know of a better way to + // distinguish `fastly help` from e.g. `fastly help pops` than this check. + if len(args) > 0 && args[len(args)-1] == "help" { + fmt.Fprintln(&buf, "\nFor help on a specific command, try e.g.") + fmt.Fprintln(&buf, "") + fmt.Fprintln(&buf, "\tfastly help profile") + fmt.Fprintln(&buf, "\tfastly profile --help") + fmt.Fprintln(&buf, "") + } + return &buf +} + +// metadata is combined into the usage output so the Developer Hub can display +// additional information about how to use the commands and what APIs they call. +// e.g. https://www.fastly.com/documentation/reference/cli/vcl/snippet/create/ +// +//go:embed metadata.json +var metadata []byte + +// commandsMetadata represents the metadata.json content that will provide extra +// contextual information. +type commandsMetadata map[string]any + +// UsageJSON returns a structured representation of the application usage +// documentation in JSON format. This is useful for machine consumption. +func UsageJSON(app *kingpin.Application) (string, error) { + var data commandsMetadata + err := json.Unmarshal(metadata, &data) + if err != nil { + return "", err + } + + usage := &usageJSON{ + GlobalFlags: getGlobalFlagJSON(app.Model().Flags), + Commands: getCommandJSON(app.Model().Commands, data), + } + + j, err := json.Marshal(usage) + if err != nil { + return "", err + } + + return string(j), nil +} + +type usageJSON struct { + GlobalFlags []flagJSON `json:"globalFlags"` + Commands []commandJSON `json:"commands"` +} + +type flagJSON struct { + Name string `json:"name"` + Description string `json:"description"` + Placeholder string `json:"placeholder"` + Required bool `json:"required"` + Default string `json:"default"` + IsBool bool `json:"isBool"` +} + +// Example represents a metadata.json command example. +type Example struct { + Cmd string `json:"cmd"` + Description string `json:"description,omitempty"` + Title string `json:"title"` +} + +type commandJSON struct { + Name string `json:"name"` + Description string `json:"description"` + Flags []flagJSON `json:"flags"` + Children []commandJSON `json:"children"` + APIs []string `json:"apis,omitempty"` + Examples []Example `json:"examples,omitempty"` +} + +func getGlobalFlagJSON(models []*kingpin.ClauseModel) []flagJSON { + var globalFlags []*kingpin.ClauseModel + for _, f := range models { + if !f.Hidden { + globalFlags = append(globalFlags, f) + } + } + return getFlagJSON(globalFlags) +} + +func getCommandJSON(models []*kingpin.CmdModel, data commandsMetadata) []commandJSON { + var cmds []commandJSON + for _, m := range models { + if m.Hidden { + continue + } + var cj commandJSON + cj.Name = m.Name + cj.Description = m.Help + cj.Flags = getFlagJSON(m.Flags) + cj.Children = getCommandJSON(m.Commands, data) + cj.APIs = []string{} + cj.Examples = []Example{} + + segs := strings.Split(m.FullCommand(), " ") + data := recurse(m.Depth, segs, data) + apis, ok := data["apis"] + if ok { + apis, ok := apis.([]any) + if ok { + for _, api := range apis { + a, ok := api.(string) + if ok { + cj.APIs = append(cj.APIs, a) + } + } + } + } + + examples, ok := data["examples"] + if ok { + examples, ok := examples.([]any) + if ok { + for _, example := range examples { + c := resolveToString(example, "cmd") + d := resolveToString(example, "description") + t := resolveToString(example, "title") + if c != "" && t != "" { + cj.Examples = append(cj.Examples, Example{ + Cmd: c, + Description: d, + Title: t, + }) + } + } + } + } + + cmds = append(cmds, cj) + } + return cmds +} + +// recurse simplifies the tree style traversal of a complex map. +// +// NOTE: The `n` arg represents the number of CLI arguments. For example, +// with `logging kafka create`, the initial function call would be passed n=3. +// The `segs` arg represents the CLI arguments. While `data` is the map data +// structure populated from the metadata.json file. +// +// Each recursive call not only decrements the `n` counter but also removes the +// previous CLI arg, so `segs` becomes shorter on each iteration. +func recurse(n int, segs []string, data commandsMetadata) commandsMetadata { + if n == 0 { + return data + } + value, ok := data[segs[0]] + if ok { + value, ok := value.(map[string]any) + if ok { + return recurse(n-1, segs[1:], value) + } + } + return nil +} + +// resolveToString extracts a value from a map as a string. +func resolveToString(i any, key string) string { + m, ok := i.(map[string]any) + if ok { + v, ok := m[key] + if ok { + v, ok := v.(string) + if ok { + return v + } + } + } + return "" +} + +func getFlagJSON(models []*kingpin.ClauseModel) []flagJSON { + var flags []flagJSON + for _, m := range models { + if m.Hidden { + continue + } + var flag flagJSON + flag.Name = m.Name + flag.Description = m.Help + flag.Placeholder = m.PlaceHolder + flag.Required = m.Required + flag.Default = strings.Join(m.Default, ",") + flag.IsBool = m.IsBoolFlag() + flags = append(flags, flag) + } + return flags +} + +// displayHelp returns a function that prints the help output for a command or +// command set. +// +// NOTE: This function is called multiple times within app.Run() and so we use +// a closure to prevent having to pass the same unchanging arguments each time. +func displayHelp( + errLog fsterr.LogInterface, + args []string, + app *kingpin.Application, + stdout, stderr io.Writer, +) func(vars map[string]any, err error) error { + return func(vars map[string]any, err error) error { + usage := Usage(args, app, stdout, stderr, vars) + remediation := fsterr.RemediationError{Prefix: usage} + if err != nil { + errLog.Add(err) + remediation.Inner = fmt.Errorf("error parsing arguments: %w", err) + } + return remediation + } +} diff --git a/pkg/argparser/cmd.go b/pkg/argparser/cmd.go new file mode 100644 index 000000000..a6f17fec9 --- /dev/null +++ b/pkg/argparser/cmd.go @@ -0,0 +1,355 @@ +package argparser + +import ( + "fmt" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + "github.com/fastly/kingpin" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/api" + "github.com/fastly/cli/pkg/env" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/text" +) + +// Command is an interface that abstracts over all of the concrete command +// structs. The Name method lets us select which command should be run, and the +// Exec method invokes whatever business logic the command should do. +type Command interface { + Name() string + Exec(in io.Reader, out io.Writer) error +} + +// Select chooses the command matching name, if it exists. +func Select(name string, commands []Command) (Command, bool) { + for _, command := range commands { + if command.Name() == name { + return command, true + } + } + return nil, false +} + +// Registerer abstracts over a kingpin.App and kingpin.CmdClause. We pass it to +// each concrete command struct's constructor as the "parent" into which the +// command should install itself. +type Registerer interface { + Command(name, help string) *kingpin.CmdClause +} + +// Globals are flags and other stuff that's useful to every command. Globals are +// passed to each concrete command's constructor as a pointer, and are populated +// after a call to Parse. A concrete command's Exec method can use any of the +// information in the globals. +type Globals struct { + Token string + Verbose bool + Client api.Interface +} + +// Base is stuff that should be included in every concrete command. +type Base struct { + CmdClause *kingpin.CmdClause + Globals *global.Data +} + +// Name implements the Command interface, and returns the FullCommand from the +// kingpin.Command that's used to select which command to actually run. +func (b Base) Name() string { + return b.CmdClause.FullCommand() +} + +// Optional models an optional type that consumers can use to assert whether the +// inner value has been set and is therefore valid for use. +type Optional struct { + WasSet bool +} + +// Set implements kingpin.Action and is used as callback to set that the optional +// inner value is valid. +func (o *Optional) Set(_ *kingpin.ParseElement, _ *kingpin.ParseContext) error { + o.WasSet = true + return nil +} + +// OptionalString models an optional string flag value. +type OptionalString struct { + Optional + Value string +} + +// OptionalStringSlice models an optional string slice flag value. +type OptionalStringSlice struct { + Optional + Value []string +} + +// OptionalBool models an optional boolean flag value. +type OptionalBool struct { + Optional + Value bool +} + +// OptionalInt models an optional int flag value. +type OptionalInt struct { + Optional + Value int +} + +// OptionalFloat64 models an optional int flag value. +type OptionalFloat64 struct { + Optional + Value float64 +} + +// ServiceDetailsOpts provides data and behaviours required by the +// ServiceDetails function. +type ServiceDetailsOpts struct { + // Active controls whether active service-versions will be included in the result; + // if this is Empty, then the 'active' state of the version is ignored; + // otherwise, the 'active' state must match the value + Active optional.Optional[bool] + // Locked controls whether locked service-versions will be included in the result; + // if this is Empty, then the 'locked' state of the version is ignored; + // otherwise, the 'locked' state must match the value + Locked optional.Optional[bool] + // Staging controls whether staging service-versions will be included in the result; + // if this is Empty, then the 'staging' state of the version is ignored; + // otherwise, the 'staging' state must match the value + Staging optional.Optional[bool] + AutoCloneFlag OptionalAutoClone + APIClient api.Interface + Manifest manifest.Data + Out io.Writer + ServiceNameFlag OptionalServiceNameID + ServiceVersionFlag OptionalServiceVersion + VerboseMode bool + ErrLog fsterr.LogInterface +} + +// ServiceDetails returns the Service ID and Service Version. +func ServiceDetails(opts ServiceDetailsOpts) (serviceID string, serviceVersion *fastly.Version, err error) { + serviceID, source, flag, err := ServiceID(opts.ServiceNameFlag, opts.Manifest, opts.APIClient, opts.ErrLog) + if err != nil { + return serviceID, serviceVersion, err + } + if opts.VerboseMode { + DisplayServiceID(serviceID, flag, source, opts.Out) + } + + v, err := opts.ServiceVersionFlag.Parse(serviceID, opts.APIClient) + if err != nil { + return serviceID, serviceVersion, err + } + + if opts.AutoCloneFlag.WasSet { + currentVersion := v + v, err = opts.AutoCloneFlag.Parse(currentVersion, serviceID, opts.VerboseMode, opts.Out, opts.APIClient) + if err != nil { + return serviceID, currentVersion, err + } + return serviceID, v, nil + } + + failure := false + var failureState string + + if active, present := opts.Active.Get(); present { + if active && !fastly.ToValue(v.Active) { + failure = true + failureState = "not active" + } + if !active && fastly.ToValue(v.Active) { + failure = true + failureState = "active" + } + } + + if locked, present := opts.Locked.Get(); present { + if locked && !fastly.ToValue(v.Locked) { + failure = true + failureState = "not locked" + } + if !locked && fastly.ToValue(v.Locked) { + failure = true + failureState = "locked" + } + } + + if staging, present := opts.Staging.Get(); present { + if staging && !fastly.ToValue(v.Staging) { + failure = true + failureState = "not staged" + } + if !staging && fastly.ToValue(v.Staging) { + failure = true + failureState = "staged" + } + } + + if failure { + err = fsterr.RemediationError{ + Inner: fmt.Errorf("service version %d is %s", fastly.ToValue(v.Number), failureState), + Remediation: fsterr.AutoCloneRemediation, + } + return serviceID, v, err + } + return serviceID, v, nil +} + +// ServiceID returns the Service ID and the source of that information. +// +// NOTE: If Service Name is provided it overrides all other methods of +// obtaining the Service ID. +func ServiceID(serviceName OptionalServiceNameID, data manifest.Data, client api.Interface, li fsterr.LogInterface) (serviceID string, source manifest.Source, flag string, err error) { + flag = "--" + FlagServiceIDName + serviceID, source = data.ServiceID() + + if serviceName.WasSet { + if source == manifest.SourceFlag { + err = fmt.Errorf("cannot specify both %s and %s", FlagServiceIDName, FlagServiceName) + if li != nil { + li.Add(err) + } + return serviceID, source, flag, err + } + + flag = "--" + FlagServiceName + serviceID, err = serviceName.Parse(client) + if err != nil { + if li != nil { + li.Add(err) + } + return serviceID, source, flag, err + } + source = manifest.SourceFlag + } + + if source == manifest.SourceUndefined { + err = fsterr.ErrNoServiceID + } + + return serviceID, source, flag, err +} + +// DisplayServiceID acquires the Service ID (if provided) and displays both it +// and its source location. +func DisplayServiceID(sid, flag string, s manifest.Source, out io.Writer) { + var via string + switch s { + case manifest.SourceFlag: + via = fmt.Sprintf(" (via %s)", flag) + case manifest.SourceFile: + via = fmt.Sprintf(" (via %s)", manifest.Filename) + case manifest.SourceEnv: + via = fmt.Sprintf(" (via %s)", env.ServiceID) + case manifest.SourceUndefined: + via = " (not provided)" + } + text.Output(out, "Service ID%s: %s", via, sid) + text.Break(out) +} + +// ArgsIsHelpJSON determines whether the supplied command arguments are exactly +// `help --format=json` or `help --format json`. +func ArgsIsHelpJSON(args []string) bool { + switch len(args) { + case 2: + if args[0] == "help" && args[1] == "--format=json" { + return true + } + case 3: + if args[0] == "help" && args[1] == "--format" && args[2] == "json" { + return true + } + } + return false +} + +// IsHelpOnly indicates if the user called `fastly help [...]`. +func IsHelpOnly(args []string) bool { + return len(args) > 0 && args[0] == "help" +} + +// IsHelpFlagOnly indicates if the user called `fastly --help [...]`. +func IsHelpFlagOnly(args []string) bool { + return len(args) > 0 && args[0] == "--help" +} + +// IsVerboseAndQuiet indicates if the user called `fastly --verbose --quiet`. +// These flags are mutually exclusive. +func IsVerboseAndQuiet(args []string) bool { + matches := map[string]bool{} + for _, a := range args { + if a == "--verbose" || a == "-v" { + matches["--verbose"] = true + } + if a == "--quiet" || a == "-q" { + matches["--quiet"] = true + } + } + return len(matches) > 1 +} + +// IsGlobalFlagsOnly indicates if the user called the binary with any +// permutation order of the globally defined flags. +// +// NOTE: Some global flags accept a value while others do not. The following +// algorithm takes this into account by mapping the flag to an expected value. +// For example, --verbose doesn't accept a value so is set to zero. +// +// EXAMPLES: +// +// The following would return false as a command was specified: +// +// args: [--verbose -v --endpoint ... --token ... -t ... --endpoint ... version] 11 +// total: 10 +// +// The following would return true as only global flags were specified: +// +// args: [--verbose -v --endpoint ... --token ... -t ... --endpoint ...] 10 +// total: 10 +// +// IMPORTANT: Kingpin doesn't support global flags. +// We hack a solution in ../app/run.go (`configureKingpin` function). +func IsGlobalFlagsOnly(args []string) bool { + // Global flags are defined in ../app/run.go + // False positive https://github.com/semgrep/semgrep/issues/8593 + // nosemgrep: trailofbits.go.iterate-over-empty-map.iterate-over-empty-map + globals := map[string]int{ + "--accept-defaults": 0, + "-d": 0, + "--account": 1, + "--api": 1, + "--auto-yes": 0, + "-y": 0, + "--debug-mode": 0, + "--enable-sso": 0, + "--help": 0, + "--non-interactive": 0, + "-i": 0, + "--profile": 1, + "-o": 1, + "--quiet": 0, + "-q": 0, + "--token": 1, + "-t": 1, + "--verbose": 0, + "-v": 0, + } + var total int + for _, a := range args { + for k := range globals { + if a == k { + total++ + total += globals[k] + } + } + } + return len(args) == total +} diff --git a/pkg/argparser/common.go b/pkg/argparser/common.go new file mode 100644 index 000000000..05e2433ec --- /dev/null +++ b/pkg/argparser/common.go @@ -0,0 +1,59 @@ +package argparser + +var ( + // FlagCustomerIDName is the flag name. + FlagCustomerIDName = "customer-id" + // FlagCustomerIDDesc is the flag description. + FlagCustomerIDDesc = "Alphanumeric string identifying the customer (falls back to FASTLY_CUSTOMER_ID)" + // FlagJSONName is the flag name. + FlagJSONName = "json" + // FlagJSONDesc is the flag description. + FlagJSONDesc = "Render output as JSON" + // FlagServiceIDName is the flag name. + FlagServiceIDName = "service-id" + // FlagServiceIDDesc is the flag description. + FlagServiceIDDesc = "Service ID (falls back to FASTLY_SERVICE_ID, then fastly.toml)" + // FlagServiceName is the flag name. + FlagServiceName = "service-name" + // FlagServiceNameDesc is the flag description. + FlagServiceNameDesc = "The name of the service" + // FlagVersionName is the flag name. + FlagVersionName = "version" + // FlagVersionDesc is the flag description. + FlagVersionDesc = "'latest', 'active', or the number of a specific Fastly service version" +) + +// PaginationDirection is a list of directions the page results can be displayed. +var PaginationDirection = []string{"ascend", "descend"} + +// CursorFlag returns a cursor flag definition. +func CursorFlag(dst *string) StringFlagOpts { + return StringFlagOpts{ + Name: "cursor", + Short: 'c', + Description: "Pagination cursor (Use 'next_cursor' value from list output)", + Dst: dst, + } +} + +// LimitFlag returns a limit flag definition. +func LimitFlag(dst *int) IntFlagOpts { + return IntFlagOpts{ + Name: "limit", + Short: 'l', + Description: "Maximum number of items to list", + Default: 50, + Dst: dst, + } +} + +// StoreIDFlag returns a store-id flag definition. +func StoreIDFlag(dst *string) StringFlagOpts { + return StringFlagOpts{ + Name: "store-id", + Short: 's', + Description: "Store ID", + Dst: dst, + Required: true, + } +} diff --git a/pkg/argparser/doc.go b/pkg/argparser/doc.go new file mode 100644 index 000000000..e31eb4aa1 --- /dev/null +++ b/pkg/argparser/doc.go @@ -0,0 +1,2 @@ +// Package argparser contains helper abstractions for working with the CLI parser. +package argparser diff --git a/pkg/argparser/fixtures/content_test.txt b/pkg/argparser/fixtures/content_test.txt new file mode 100644 index 000000000..793aa682b --- /dev/null +++ b/pkg/argparser/fixtures/content_test.txt @@ -0,0 +1 @@ +This is a test \ No newline at end of file diff --git a/pkg/argparser/flags.go b/pkg/argparser/flags.go new file mode 100644 index 000000000..ae62b3c2c --- /dev/null +++ b/pkg/argparser/flags.go @@ -0,0 +1,353 @@ +package argparser + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + + "github.com/fastly/go-fastly/v10/fastly" + "github.com/fastly/kingpin" + + "github.com/fastly/cli/pkg/api" + "github.com/fastly/cli/pkg/env" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/text" +) + +var ( + completionRegExp = regexp.MustCompile("completion-bash$") + completionScriptRegExp = regexp.MustCompile("completion-script-(?:bash|zsh)$") +) + +// StringFlagOpts enables easy configuration of a flag. +type StringFlagOpts struct { + Action kingpin.Action + Description string + Dst *string + Name string + Required bool + Short rune +} + +// RegisterFlag defines a flag. +func (b Base) RegisterFlag(opts StringFlagOpts) { + clause := b.CmdClause.Flag(opts.Name, opts.Description) + if opts.Short > 0 { + clause = clause.Short(opts.Short) + } + if opts.Required { + clause = clause.Required() + } + if opts.Action != nil { + clause = clause.Action(opts.Action) + } + clause.StringVar(opts.Dst) +} + +// BoolFlagOpts enables easy configuration of a flag. +type BoolFlagOpts struct { + Action kingpin.Action + Description string + Dst *bool + Name string + Required bool + Short rune +} + +// RegisterFlagBool defines a boolean flag. +// +// TODO: Use generics support in go 1.18 to remove the need for multiple functions. +func (b Base) RegisterFlagBool(opts BoolFlagOpts) { + clause := b.CmdClause.Flag(opts.Name, opts.Description) + if opts.Short > 0 { + clause = clause.Short(opts.Short) + } + if opts.Required { + clause = clause.Required() + } + if opts.Action != nil { + clause = clause.Action(opts.Action) + } + clause.BoolVar(opts.Dst) +} + +// IntFlagOpts enables easy configuration of a flag. +type IntFlagOpts struct { + Action kingpin.Action + Default int + Description string + Dst *int + Name string + Required bool + Short rune +} + +// RegisterFlagInt defines an integer flag. +func (b Base) RegisterFlagInt(opts IntFlagOpts) { + clause := b.CmdClause.Flag(opts.Name, opts.Description) + if opts.Short > 0 { + clause = clause.Short(opts.Short) + } + if opts.Required { + clause = clause.Required() + } + if opts.Action != nil { + clause = clause.Action(opts.Action) + } + if opts.Default != 0 { + clause = clause.Default(strconv.Itoa(opts.Default)) + } + clause.IntVar(opts.Dst) +} + +// OptionalServiceVersion represents a Fastly service version. +type OptionalServiceVersion struct { + OptionalString +} + +// Parse returns a service version based on the given user input. +func (sv *OptionalServiceVersion) Parse(sid string, client api.Interface) (*fastly.Version, error) { + vs, err := client.ListVersions(&fastly.ListVersionsInput{ + ServiceID: sid, + }) + if err != nil { + return nil, fmt.Errorf("error listing service versions: %w", err) + } + if len(vs) == 0 { + return nil, errors.New("error listing service versions: no versions available") + } + + // Sort versions into descending order. + sort.Slice(vs, func(i, j int) bool { + return fastly.ToValue(vs[i].Number) > fastly.ToValue(vs[j].Number) + }) + + var v *fastly.Version + + switch strings.ToLower(sv.Value) { + case "latest": + return vs[0], nil + case "active": + v, err = GetActiveVersion(vs) + case "": // no --version flag provided + v, err = GetActiveVersion(vs) + if err != nil { + return vs[0], nil //lint:ignore nilerr if no active version, return latest version + } + default: + v, err = GetSpecifiedVersion(vs, sv.Value) + } + if err != nil { + return nil, err + } + + return v, nil +} + +// OptionalServiceNameID represents a mapping between a Fastly service name and +// its ID. +type OptionalServiceNameID struct { + OptionalString +} + +// Parse returns a service ID based off the given service name. +func (sv *OptionalServiceNameID) Parse(client api.Interface) (serviceID string, err error) { + paginator := client.GetServices(&fastly.GetServicesInput{}) + var services []*fastly.Service + for paginator.HasNext() { + data, err := paginator.GetNext() + if err != nil { + return serviceID, fmt.Errorf("error listing services: %w", err) + } + services = append(services, data...) + } + for _, s := range services { + if fastly.ToValue(s.Name) == sv.Value { + return fastly.ToValue(s.ServiceID), nil + } + } + return serviceID, errors.New("error matching service name with available services") +} + +// OptionalCustomerID represents a Fastly customer ID. +type OptionalCustomerID struct { + OptionalString +} + +// Parse returns a customer ID either from a flag or from a user defined +// environment variable (see pkg/env/env.go). +// +// NOTE: Will fallback to FASTLY_CUSTOMER_ID environment variable if no flag value set. +func (sv *OptionalCustomerID) Parse() error { + if sv.Value == "" { + if e := os.Getenv(env.CustomerID); e != "" { + sv.Value = e + return nil + } + return fsterr.ErrNoCustomerID + } + return nil +} + +// AutoCloneFlagOpts enables easy configuration of the --autoclone flag defined +// via the RegisterAutoCloneFlag constructor. +type AutoCloneFlagOpts struct { + Action kingpin.Action + Dst *bool +} + +// RegisterAutoCloneFlag defines a --autoclone flag that will cause a clone of the +// identified service version if it's found to be active or locked. +func (b Base) RegisterAutoCloneFlag(opts AutoCloneFlagOpts) { + b.CmdClause.Flag("autoclone", "If the selected service version is not editable, clone it and use the clone.").Action(opts.Action).BoolVar(opts.Dst) +} + +// OptionalAutoClone defines a method set for abstracting the logic required to +// identify if a given service version needs to be cloned. +type OptionalAutoClone struct { + OptionalBool +} + +// Parse returns a service version. +// +// The returned version is either the same as the input argument `v` or it's a +// cloned version if the input argument was either active or locked. +func (ac *OptionalAutoClone) Parse(v *fastly.Version, sid string, verbose bool, out io.Writer, client api.Interface) (*fastly.Version, error) { + // if user didn't provide --autoclone flag + if !ac.Value && (fastly.ToValue(v.Active) || fastly.ToValue(v.Locked)) { + return nil, fsterr.RemediationError{ + Inner: fmt.Errorf("service version %d is not editable", fastly.ToValue(v.Number)), + Remediation: fsterr.AutoCloneRemediation, + } + } + if ac.Value && (v.Active != nil && *v.Active || v.Locked != nil && *v.Locked) { + version, err := client.CloneVersion(&fastly.CloneVersionInput{ + ServiceID: sid, + ServiceVersion: fastly.ToValue(v.Number), + }) + if err != nil { + return nil, fmt.Errorf("error cloning service version: %w", err) + } + if verbose { + msg := "Service version %d is not editable, so it was automatically cloned because --autoclone is enabled. Now operating on version %d.\n\n" + format := fmt.Sprintf(msg, fastly.ToValue(v.Number), fastly.ToValue(version.Number)) + text.Info(out, format) + } + return version, nil + } + + // Treat the function as a no-op if the version is editable. + return v, nil +} + +// GetActiveVersion returns the active service version. +func GetActiveVersion(vs []*fastly.Version) (*fastly.Version, error) { + for _, v := range vs { + if fastly.ToValue(v.Active) { + return v, nil + } + } + return nil, fmt.Errorf("no active service version found") +} + +// GetSpecifiedVersion returns the specified service version. +func GetSpecifiedVersion(vs []*fastly.Version, version string) (*fastly.Version, error) { + i, err := strconv.Atoi(version) + if err != nil { + return nil, err + } + + for _, v := range vs { + if fastly.ToValue(v.Number) == i { + return v, nil + } + } + + return nil, fmt.Errorf("specified service version not found: %s", version) +} + +// Content determines if the given flag value is a file path, and if so read +// the contents from disk, otherwise presume the given value is the content. +func Content(flagval string) string { + content := flagval + if path, err := filepath.Abs(flagval); err == nil { + if _, err := os.Stat(path); err == nil { + if data, err := os.ReadFile(path); err == nil /* #nosec */ { + content = string(data) + } + } + } + return content +} + +// IntToBool converts a binary 0|1 to a boolean. +func IntToBool(i int) bool { + return i > 0 +} + +// ContextHasHelpFlag asserts whether a given kingpin.ParseContext contains a +// `help` flag. +func ContextHasHelpFlag(ctx *kingpin.ParseContext) bool { + _, ok := ctx.Elements.FlagMap()["help"] + return ok +} + +// IsCompletionScript determines whether the supplied command arguments are for +// shell completion output that is then eval()'ed by the user's shell. +func IsCompletionScript(args []string) bool { + var found bool + for _, arg := range args { + if completionScriptRegExp.MatchString(arg) { + found = true + } + } + return found +} + +// IsCompletion determines whether the supplied command arguments are for +// shell completion (i.e. --completion-bash) that should produce output that +// the user's shell can utilise for handling autocomplete behaviour. +func IsCompletion(args []string) bool { + var found bool + for _, arg := range args { + if completionRegExp.MatchString(arg) { + found = true + } + } + return found +} + +// JSONOutput is a helper for adding a `--json` flag and encoding +// values to JSON. It can be embedded into command structs. +type JSONOutput struct { + Enabled bool // Set via flag. +} + +// JSONFlag creates a flag for enabling JSON output. +func (j *JSONOutput) JSONFlag() BoolFlagOpts { + return BoolFlagOpts{ + Name: FlagJSONName, + Description: FlagJSONDesc, + Dst: &j.Enabled, + Short: 'j', + } +} + +// WriteJSON checks whether the enabled flag is set or not. If set, +// then the given value is written as JSON to out. Otherwise, false is returned. +func (j *JSONOutput) WriteJSON(out io.Writer, value any) (bool, error) { + if !j.Enabled { + return false, nil + } + + enc := json.NewEncoder(out) + enc.SetIndent("", " ") + return true, enc.Encode(value) +} diff --git a/pkg/argparser/flags_test.go b/pkg/argparser/flags_test.go new file mode 100644 index 000000000..cdddf9113 --- /dev/null +++ b/pkg/argparser/flags_test.go @@ -0,0 +1,458 @@ +package argparser_test + +import ( + "bytes" + "fmt" + "io" + "net/http" + "sort" + "strconv" + "strings" + "testing" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" +) + +func TestOptionalServiceVersionParse(t *testing.T) { + cases := map[string]struct { + flagValue string + flagOmitted bool + wantVersion int + errExpected bool + }{ + "latest": { + flagValue: "latest", + wantVersion: 4, + }, + "active": { + flagValue: "active", + wantVersion: 1, + }, + // NOTE: Default behaviour for an empty flag value (or no flag at all) is to + // get the active version, and if no active version return the latest. + "empty": { + flagValue: "", + wantVersion: 1, + }, + "omitted": { + flagOmitted: true, + wantVersion: 1, + }, + "specific version OK": { + flagValue: "2", + wantVersion: 2, + }, + "specific version ERR": { + flagValue: "5", + errExpected: true, // there is no version 5 + }, + } + + for name, c := range cases { + t.Run(name, func(t *testing.T) { + sv := &argparser.OptionalServiceVersion{} + + if !c.flagOmitted { + sv.OptionalString = argparser.OptionalString{ + Value: c.flagValue, + } + } + + v, err := sv.Parse("123", mock.API{ + ListVersionsFn: listVersions, + }) + if err != nil { + if c.errExpected { + return + } + t.Fatalf("unexpected error: %v", err) + } + if err == nil { + if c.errExpected { + t.Fatalf("expected error, have %v", v) + } + } + + want := c.wantVersion + have := fastly.ToValue(v.Number) + if have != want { + t.Errorf("wanted %d, have %d", want, have) + } + }) + } +} + +// listVersions returns a list of service versions in different states. +// +// The first element is active, the second is locked, the third is +// editable, the fourth is staged. +func listVersions(i *fastly.ListVersionsInput) ([]*fastly.Version, error) { + return []*fastly.Version{ + { + ServiceID: fastly.ToPointer(i.ServiceID), + Number: fastly.ToPointer(1), + Active: fastly.ToPointer(true), + UpdatedAt: testutil.MustParseTimeRFC3339("2000-01-01T01:00:00Z"), + }, + { + ServiceID: fastly.ToPointer(i.ServiceID), + Number: fastly.ToPointer(2), + Locked: fastly.ToPointer(true), + UpdatedAt: testutil.MustParseTimeRFC3339("2000-01-02T01:00:00Z"), + }, + { + ServiceID: fastly.ToPointer(i.ServiceID), + Number: fastly.ToPointer(3), + UpdatedAt: testutil.MustParseTimeRFC3339("2000-01-03T01:00:00Z"), + }, + { + ServiceID: fastly.ToPointer(i.ServiceID), + Number: fastly.ToPointer(4), + Staging: fastly.ToPointer(true), + UpdatedAt: testutil.MustParseTimeRFC3339("2000-01-04T01:00:00Z"), + }, + }, nil +} + +func TestGetLatestActiveVersion(t *testing.T) { + for _, testcase := range []struct { + name string + inputVersions []*fastly.Version + wantVersion int + wantError string + }{ + { + name: "active", + inputVersions: []*fastly.Version{ + {Number: fastly.ToPointer(1), Active: fastly.ToPointer(false), UpdatedAt: testutil.MustParseTimeRFC3339("2000-01-01T01:00:00Z")}, + {Number: fastly.ToPointer(2), Active: fastly.ToPointer(true), UpdatedAt: testutil.MustParseTimeRFC3339("2000-01-02T01:00:00Z")}, + }, + wantVersion: 2, + }, + { + name: "draft", + inputVersions: []*fastly.Version{ + {Number: fastly.ToPointer(1), Active: fastly.ToPointer(true), UpdatedAt: testutil.MustParseTimeRFC3339("2000-01-01T01:00:00Z")}, + {Number: fastly.ToPointer(2), Active: fastly.ToPointer(false), UpdatedAt: testutil.MustParseTimeRFC3339("2000-01-02T01:00:00Z")}, + }, + wantVersion: 1, + }, + { + name: "locked", + inputVersions: []*fastly.Version{ + {Number: fastly.ToPointer(1), Active: fastly.ToPointer(true), UpdatedAt: testutil.MustParseTimeRFC3339("2000-01-01T01:00:00Z")}, + {Number: fastly.ToPointer(2), Active: fastly.ToPointer(false), Locked: fastly.ToPointer(true), UpdatedAt: testutil.MustParseTimeRFC3339("2000-01-02T01:00:00Z")}, + }, + wantVersion: 1, + }, + { + name: "no active", + inputVersions: []*fastly.Version{ + {Number: fastly.ToPointer(1), Active: fastly.ToPointer(false), UpdatedAt: testutil.MustParseTimeRFC3339("2000-01-01T01:00:00Z")}, + {Number: fastly.ToPointer(2), Active: fastly.ToPointer(false), UpdatedAt: testutil.MustParseTimeRFC3339("2000-01-02T01:00:00Z")}, + {Number: fastly.ToPointer(3), Active: fastly.ToPointer(false), UpdatedAt: testutil.MustParseTimeRFC3339("2000-01-03T01:00:00Z")}, + }, + wantError: "no active service version found", + }, + } { + t.Run(testcase.name, func(t *testing.T) { + // NOTE: this is a duplicate of the sorting algorithm in + // cmd/command.go to make the test as realistic as possible + sort.Slice(testcase.inputVersions, func(i, j int) bool { + return fastly.ToValue(testcase.inputVersions[i].Number) > fastly.ToValue(testcase.inputVersions[j].Number) + }) + + v, err := argparser.GetActiveVersion(testcase.inputVersions) + if err != nil { + if testcase.wantError != "" { + testutil.AssertString(t, testcase.wantError, err.Error()) + } else { + t.Errorf("unexpected error returned: %v", err) + } + } else if fastly.ToValue(v.Number) != testcase.wantVersion { + t.Errorf("wanted version %d, got %d", testcase.wantVersion, v.Number) + } + }) + } +} + +func TestGetSpecifiedVersion(t *testing.T) { + for _, testcase := range []struct { + name string + inputVersions []*fastly.Version + wantVersion int + wantError string + }{ + { + name: "success", + inputVersions: []*fastly.Version{ + {Number: fastly.ToPointer(1), Active: fastly.ToPointer(false), UpdatedAt: testutil.MustParseTimeRFC3339("2000-01-01T01:00:00Z")}, + {Number: fastly.ToPointer(2), Active: fastly.ToPointer(true), UpdatedAt: testutil.MustParseTimeRFC3339("2000-01-02T01:00:00Z")}, + }, + wantVersion: 1, + }, + { + name: "no version available", + inputVersions: []*fastly.Version{ + {Number: fastly.ToPointer(1), Active: fastly.ToPointer(false), Locked: fastly.ToPointer(true), UpdatedAt: testutil.MustParseTimeRFC3339("2000-01-01T01:00:00Z")}, + {Number: fastly.ToPointer(2), Active: fastly.ToPointer(false), Locked: fastly.ToPointer(true), UpdatedAt: testutil.MustParseTimeRFC3339("2000-02-02T01:00:00Z")}, + {Number: fastly.ToPointer(3), Active: fastly.ToPointer(true), UpdatedAt: testutil.MustParseTimeRFC3339("2000-03-03T01:00:00Z")}, + }, + wantVersion: 4, + wantError: "specified service version not found: 4", + }, + } { + t.Run(testcase.name, func(t *testing.T) { + // NOTE: this is a duplicate of the sorting algorithm in + // cmd/command.go to make the test as realistic as possible + sort.Slice(testcase.inputVersions, func(i, j int) bool { + return fastly.ToValue(testcase.inputVersions[i].Number) > fastly.ToValue(testcase.inputVersions[j].Number) + }) + + v, err := argparser.GetSpecifiedVersion(testcase.inputVersions, strconv.Itoa(testcase.wantVersion)) + if err != nil { + if testcase.wantError != "" { + testutil.AssertString(t, testcase.wantError, err.Error()) + } else { + t.Errorf("unexpected error returned: %v", err) + } + } else if fastly.ToValue(v.Number) != testcase.wantVersion { + t.Errorf("wanted version %d, got %d", testcase.wantVersion, v.Number) + } + }) + } +} + +func TestOptionalAutoCloneParse(t *testing.T) { + cases := map[string]struct { + version *fastly.Version + flagOmitted bool + wantVersion int + errExpected bool + expectEditable bool + }{ + "version is editable": { + version: &fastly.Version{ + Number: fastly.ToPointer(1), + }, + wantVersion: 1, + expectEditable: true, + }, + "version is locked": { + version: &fastly.Version{ + Number: fastly.ToPointer(1), + Locked: fastly.ToPointer(true), + }, + wantVersion: 2, + }, + "version is active": { + version: &fastly.Version{ + Number: fastly.ToPointer(1), + Active: fastly.ToPointer(true), + }, + wantVersion: 2, + }, + "version is locked but flag omitted": { + version: &fastly.Version{ + Number: fastly.ToPointer(1), + Locked: fastly.ToPointer(true), + }, + flagOmitted: true, + errExpected: true, + }, + "version is active but flag omitted": { + version: &fastly.Version{ + Number: fastly.ToPointer(1), + Active: fastly.ToPointer(true), + }, + flagOmitted: true, + errExpected: true, + }, + } + + for name, c := range cases { + t.Run(name, func(t *testing.T) { + var ( + acv *argparser.OptionalAutoClone + bs []byte + ) + buf := bytes.NewBuffer(bs) + + if c.flagOmitted { + acv = &argparser.OptionalAutoClone{} + } else { + acv = &argparser.OptionalAutoClone{ + OptionalBool: argparser.OptionalBool{ + Value: true, + }, + } + } + + verboseMode := true + v, err := acv.Parse(c.version, "123", verboseMode, buf, mock.API{ + CloneVersionFn: cloneVersionResult(fastly.ToValue(c.version.Number) + 1), + }) + if err != nil { + if c.errExpected && errMatches(fastly.ToValue(c.version.Number), err) { + return + } + t.Fatalf("unexpected error: %v", err) + } + if err == nil { + if c.errExpected { + t.Fatalf("expected error, have %v", v) + } + } + + want := c.wantVersion + have := fastly.ToValue(v.Number) + if have != want { + t.Errorf("wanted %d, have %d", want, have) + } + + if !c.expectEditable { + want := fmt.Sprintf("Service version %d is not editable, so it was automatically cloned because --autoclone is enabled. Now operating on version %d.", fastly.ToValue(c.version.Number), fastly.ToValue(v.Number)) + have := strings.Trim(strings.ReplaceAll(buf.String(), "\n", " "), " ") + if !strings.Contains(have, want) { + t.Errorf("wanted %s, have %s", want, have) + } + } + }) + } +} + +func TestServiceID(t *testing.T) { + cases := map[string]struct { + ServiceName argparser.OptionalServiceNameID + Data manifest.Data + API mock.API + WantServiceID string + WantError string + WantSource manifest.Source + WantFlag string + }{ + "service-id flag": { + Data: manifest.Data{ + Flag: manifest.Flag{ServiceID: "456"}, + }, + WantServiceID: "456", + WantSource: manifest.SourceFlag, + WantFlag: argparser.FlagServiceIDName, + }, + "service ID in manifest": { + Data: manifest.Data{ + File: manifest.File{ServiceID: "456"}, + }, + WantServiceID: "456", + WantSource: manifest.SourceFile, + }, + "service-name flag with service-id flag": { + ServiceName: argparser.OptionalServiceNameID{argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "bar"}}, + Data: manifest.Data{ + Flag: manifest.Flag{ServiceID: "123"}, + }, + WantError: "cannot specify both service-id and service-name", + }, + "service-name flag with service-id in file": { + ServiceName: argparser.OptionalServiceNameID{argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "bar"}}, + Data: manifest.Data{ + File: manifest.File{ServiceID: "123"}, + }, + API: mock.API{ + GetServicesFn: func(_ *fastly.GetServicesInput) *fastly.ListPaginator[fastly.Service] { + return fastly.NewPaginator[fastly.Service](&mock.HTTPClient{ + Errors: []error{nil}, + Responses: []*http.Response{ + { + Body: io.NopCloser(strings.NewReader(`[{"id": "456", "name": "bar"}]`)), + }, + }, + }, fastly.ListOpts{}, "/example") + }, + }, + WantServiceID: "456", + WantSource: manifest.SourceFlag, + WantFlag: argparser.FlagServiceName, + }, + "unknown service-name flag value": { + ServiceName: argparser.OptionalServiceNameID{argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "bar"}}, + Data: manifest.Data{}, + API: mock.API{ + GetServicesFn: func(_ *fastly.GetServicesInput) *fastly.ListPaginator[fastly.Service] { + return fastly.NewPaginator[fastly.Service](&mock.HTTPClient{ + Errors: []error{nil}, + Responses: []*http.Response{ + { + Body: io.NopCloser(strings.NewReader(`[{"id": "456", "name": "beepboop"}]`)), + }, + }, + }, fastly.ListOpts{}, "/example") + }, + }, + WantError: "error matching service name with available services", + }, + "no information provided": { + Data: manifest.Data{}, + WantError: "error reading service: no service ID found", + }, + } + + for name, c := range cases { + t.Run(name, func(t *testing.T) { + serviceID, source, flag, err := argparser.ServiceID(c.ServiceName, c.Data, c.API, nil) + testutil.AssertErrorContains(t, err, c.WantError) + if err == nil { + testutil.AssertString(t, serviceID, c.WantServiceID) + testutil.AssertStringContains(t, flag, c.WantFlag) + testutil.AssertEqual(t, source, c.WantSource) + } + }) + } +} + +func TestContent(t *testing.T) { + const expectedContent = "This is a test" + const expectedPath = "fixtures/content_test.txt" + for _, testcase := range []struct { + name string + content string + }{ + { + name: "regular string", + content: expectedContent, + }, + { + name: "path", + content: expectedPath, + }, + } { + t.Run(testcase.name, func(t *testing.T) { + content := argparser.Content(testcase.content) + if content != expectedContent { + t.Errorf("for test %s, wanted content %s, got %s", testcase.name, expectedContent, content) + } + }) + } +} + +// cloneVersionResult returns a function which returns a specific cloned version. +func cloneVersionResult(version int) func(i *fastly.CloneVersionInput) (*fastly.Version, error) { + return func(i *fastly.CloneVersionInput) (*fastly.Version, error) { + return &fastly.Version{ + ServiceID: fastly.ToPointer(i.ServiceID), + Number: fastly.ToPointer(version), + }, nil + } +} + +// errMatches validates that the error message is what we expect when given a +// service version that is either locked or active, while also not providing +// the --autoclone flag. +func errMatches(version int, err error) bool { + return err.Error() == fmt.Sprintf("service version %d is not editable", version) +} diff --git a/pkg/auth/auth.go b/pkg/auth/auth.go new file mode 100644 index 000000000..b872f40a1 --- /dev/null +++ b/pkg/auth/auth.go @@ -0,0 +1,466 @@ +package auth + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/hashicorp/cap/jwt" + "github.com/hashicorp/cap/oidc" + + "github.com/fastly/cli/pkg/api" + "github.com/fastly/cli/pkg/api/undocumented" + "github.com/fastly/cli/pkg/config" + "github.com/fastly/cli/pkg/debug" + fsterr "github.com/fastly/cli/pkg/errors" +) + +// Remediation is a generic remediation message for an error authorizing. +const Remediation = "Please re-run the command. If the problem persists, please file an issue: https://github.com/fastly/cli/issues/new?labels=bug&template=bug_report.md" + +// ClientID is the auth provider's Client ID. +const ClientID = "fastly-cli" + +// RedirectURL is the endpoint the auth provider will pass an authorization code to. +const RedirectURL = "http://localhost:8080/callback" + +// OIDCMetadata is OpenID Connect's metadata discovery mechanism. +// https://swagger.io/docs/specification/authentication/openid-connect-discovery/ +const OIDCMetadata = "%s/realms/fastly/.well-known/openid-configuration" + +// ErrInvalidGrant represents an error refreshing the user's token. +var ErrInvalidGrant = errors.New("failed to refresh token: invalid grant") + +// WellKnownEndpoints represents the OpenID Connect metadata. +type WellKnownEndpoints struct { + // Auth is the authorization_endpoint. + Auth string `json:"authorization_endpoint"` + // Certs is the jwks_uri. + Certs string `json:"jwks_uri"` + // Token is the token_endpoint. + Token string `json:"token_endpoint"` +} + +// Runner defines the behaviour for the authentication server. +type Runner interface { + // AuthURL returns a fully qualified authorization_endpoint. + // i.e. path + audience + scope + code_challenge etc. + AuthURL() (string, error) + // GetResult returns the results channel + GetResult() chan AuthorizationResult + // RefreshAccessToken constructs and calls the token_endpoint with the + // refresh token so we can refresh and return the access token. + RefreshAccessToken(refreshToken string) (JWT, error) + // SetParam sets the specified parameter for the authorization_endpoint. + // https://openid.net/specs/openid-connect-basic-1_0.html#rfc.section.2.1.1.1 + SetParam(field, value string) + // Start starts a local server for handling authentication processing. + Start() error + // ValidateAndRetrieveAPIToken verifies the signature and the claims and + // exchanges the access token for an API token. + ValidateAndRetrieveAPIToken(accessToken string) (string, *APIToken, error) +} + +// Server is a local server responsible for authentication processing. +type Server struct { + // APIEndpoint is the API endpoint. + APIEndpoint string + // AccountEndpoint is the accounts endpoint. + AccountEndpoint string + // DebugMode indicates to the CLI it can display debug information. + DebugMode string + // HTTPClient is a HTTP client used to call the API to exchange the access token for a session token. + HTTPClient api.HTTPClient + // Params are additional parameters for the authorization_endpoint. + Params []Param + // Result is a channel that reports the result of authorization. + Result chan AuthorizationResult + // Router is an HTTP request multiplexer. + Router *http.ServeMux + // Verifier represents an OAuth PKCE code verifier that uses the S256 challenge method. + Verifier *oidc.S256Verifier + // WellKnownEndpoints is the .well-known metadata. + WellKnownEndpoints WellKnownEndpoints +} + +// Param is an individual parameter set on the authorization_endpoint. +type Param struct { + Field string + Value string +} + +// AuthURL returns a fully qualified authorization_endpoint. +// i.e. path + audience + scope + code_challenge etc. +func (s Server) AuthURL() (string, error) { + challenge, err := oidc.CreateCodeChallenge(s.Verifier) + if err != nil { + return "", err + } + params := url.Values{} + params.Add("audience", s.APIEndpoint) + params.Add("scope", "openid") + params.Add("response_type", "code") + params.Add("client_id", ClientID) + params.Add("code_challenge", challenge) + params.Add("code_challenge_method", "S256") + params.Add("redirect_uri", RedirectURL) + for _, p := range s.Params { + params.Add(p.Field, p.Value) + } + return fmt.Sprintf("%s?%s", s.WellKnownEndpoints.Auth, params.Encode()), nil +} + +// SetParam sets the specified parameter for the authorization_endpoint. +func (s *Server) SetParam(field, value string) { + s.Params = append(s.Params, Param{field, value}) +} + +// GetResult returns the result channel. +func (s Server) GetResult() chan AuthorizationResult { + return s.Result +} + +// GetJWT constructs and calls the token_endpoint path, returning a JWT +// containing the access and refresh tokens and associated TTLs. +func (s Server) GetJWT(authorizationCode string) (JWT, error) { + payload := fmt.Sprintf( + "grant_type=authorization_code&client_id=%s&code_verifier=%s&code=%s&redirect_uri=%s", + ClientID, + s.Verifier.Verifier(), + authorizationCode, + "http://localhost:8080/callback", // NOTE: not redirected to, just a security check. + ) + + req, err := http.NewRequest(http.MethodPost, s.WellKnownEndpoints.Token, strings.NewReader(payload)) + if err != nil { + return JWT{}, err + } + req.Header.Add("content-type", "application/x-www-form-urlencoded") + + debugMode, _ := strconv.ParseBool(s.DebugMode) + + if debugMode { + debug.DumpHTTPRequest(req) + } + res, err := http.DefaultClient.Do(req) + if debugMode { + debug.DumpHTTPResponse(res) + } + + if err != nil { + return JWT{}, err + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return JWT{}, fmt.Errorf("failed to exchange code for jwt (status: %s)", res.Status) + } + + body, err := io.ReadAll(res.Body) + if err != nil { + return JWT{}, err + } + + var j JWT + err = json.Unmarshal(body, &j) + if err != nil { + return JWT{}, err + } + + return j, nil +} + +// SetVerifier sets the code verifier endpoint. +func (s *Server) SetVerifier(verifier *oidc.S256Verifier) { + s.Verifier = verifier +} + +// Start starts a local server for handling authentication processing. +func (s *Server) Start() error { + server := &http.Server{ + Addr: ":8080", + Handler: s.Router, + ReadTimeout: 10 * time.Second, + WriteTimeout: 10 * time.Second, + } + + err := server.ListenAndServe() + if err != nil { + return fsterr.RemediationError{ + Inner: fmt.Errorf("failed to start local server: %w", err), + Remediation: Remediation, + } + } + return nil +} + +// HandleCallback processes the callback from the authentication service. +func (s *Server) HandleCallback() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + authorizationCode := r.URL.Query().Get("code") + if authorizationCode == "" { + fmt.Fprint(w, "ERROR: no authorization code returned\n") + s.Result <- AuthorizationResult{ + Err: fmt.Errorf("no authorization code returned"), + } + return + } + + // Exchange the authorization code and the code verifier for a JWT. + // NOTE: I use the identifier `j` to avoid overlap with the `jwt` package. + j, err := s.GetJWT(authorizationCode) + if err != nil || j.AccessToken == "" || j.IDToken == "" { + fmt.Fprint(w, "ERROR: failed to exchange code for JWT\n") + s.Result <- AuthorizationResult{ + Err: fmt.Errorf("failed to exchange code for JWT"), + } + return + } + + email, at, err := s.ValidateAndRetrieveAPIToken(j.AccessToken) + if err != nil { + s.Result <- AuthorizationResult{ + Err: err, + } + return + } + + fmt.Fprint(w, "Authenticated successfully. Please close this page and return to the Fastly CLI in your terminal.") + s.Result <- AuthorizationResult{ + Email: email, + Jwt: j, + SessionToken: at.AccessToken, + } + } +} + +// ValidateAndRetrieveAPIToken verifies the signature and the claims and +// exchanges the access token for an API token. +// +// NOTE: This function exists as it's called by this package + app.Run(). +func (s *Server) ValidateAndRetrieveAPIToken(accessToken string) (string, *APIToken, error) { + claims, err := s.VerifyJWTSignature(accessToken) + if err != nil { + return "", nil, err + } + + azp, ok := claims["azp"] + if !ok { + return "", nil, errors.New("failed to extract azp from JWT claims") + } + if azp != ClientID { + if !ok { + return "", nil, fmt.Errorf("failed to match expected azp: %s", azp) + } + } + + aud, ok := claims["aud"] + if !ok { + return "", nil, errors.New("failed to extract aud from JWT claims") + } + + if aud != s.APIEndpoint { + if !ok { + return "", nil, fmt.Errorf("failed to match expected aud: %s", s.APIEndpoint) + } + } + + email, ok := claims["email"] + if !ok { + return "", nil, errors.New("failed to extract email from JWT claims") + } + + // Exchange the access token for a Fastly API token. + at, err := s.ExchangeAccessToken(accessToken) + if err != nil { + return "", nil, fmt.Errorf("failed to exchange access token for an API token: %w", err) + } + + e, ok := email.(string) + if !ok { + return "", nil, fmt.Errorf("failed to type assert 'email' (%#v) to a string", email) + } + return e, at, nil +} + +// VerifyJWTSignature calls the jwks_uri endpoint and extracts its claims. +func (s *Server) VerifyJWTSignature(accessToken string) (claims map[string]any, err error) { + ctx := context.Background() + + // NOTE: The last argument is optional and is for validating the JWKs endpoint + // (which we don't need to do, so we pass an empty string) + keySet, err := jwt.NewJSONWebKeySet(ctx, s.WellKnownEndpoints.Certs, "") + if err != nil { + return claims, fmt.Errorf("failed to verify signature of access token: %w", err) + } + + claims, err = keySet.VerifySignature(ctx, accessToken) + if err != nil { + return nil, fmt.Errorf("failed to verify signature of access token: %w", err) + } + + return claims, nil +} + +// ExchangeAccessToken exchanges `accessToken` for a Fastly API token. +func (s *Server) ExchangeAccessToken(accessToken string) (*APIToken, error) { + debug, _ := strconv.ParseBool(s.DebugMode) + resp, err := undocumented.Call(undocumented.CallOptions{ + APIEndpoint: s.APIEndpoint, + HTTPClient: s.HTTPClient, + HTTPHeaders: []undocumented.HTTPHeader{ + { + Key: "Authorization", + Value: fmt.Sprintf("Bearer %s", accessToken), + }, + }, + Method: http.MethodPost, + Path: "/login-enhanced", + Debug: debug, + }) + if err != nil { + if apiErr, ok := err.(undocumented.APIError); ok { + if apiErr.StatusCode != http.StatusConflict { + err = fmt.Errorf("%w: %d %s", err, apiErr.StatusCode, http.StatusText(apiErr.StatusCode)) + } + } + return nil, err + } + + at := &APIToken{} + err = json.Unmarshal(resp, at) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal json containing API token: %w", err) + } + + return at, nil +} + +// RefreshAccessToken constructs and calls the token_endpoint with the +// refresh token so we can refresh and return the access token. +func (s *Server) RefreshAccessToken(refreshToken string) (JWT, error) { + payload := fmt.Sprintf( + "grant_type=refresh_token&client_id=%s&refresh_token=%s", + ClientID, + refreshToken, + ) + + req, err := http.NewRequest(http.MethodPost, s.WellKnownEndpoints.Token, strings.NewReader(payload)) + if err != nil { + return JWT{}, err + } + req.Header.Add("content-type", "application/x-www-form-urlencoded") + + debugMode, _ := strconv.ParseBool(s.DebugMode) + if debugMode { + debug.DumpHTTPRequest(req) + } + res, err := http.DefaultClient.Do(req) + if debugMode { + debug.DumpHTTPResponse(res) + } + + if err != nil { + return JWT{}, err + } + defer res.Body.Close() + + body, err := io.ReadAll(res.Body) + if err != nil { + return JWT{}, err + } + + if res.StatusCode != http.StatusOK { + var re RefreshError + err = json.Unmarshal(body, &re) + if err != nil { + return JWT{}, err + } + + if re.Error == "invalid_grant" { + return JWT{}, ErrInvalidGrant + } + return JWT{}, fmt.Errorf("non-2xx status: %s", res.Status) + } + + var j JWT + err = json.Unmarshal(body, &j) + if err != nil { + return JWT{}, err + } + + return j, nil +} + +// RefreshError represents an error when refreshing the user's token. +type RefreshError struct { + Error string `json:"error"` + Description string `json:"error_description"` +} + +// APIToken is returned from the /login-enhanced endpoint. +type APIToken struct { + // AccessToken is used to access the Fastly API. + AccessToken string `json:"access_token"` + // CustomerID is the customer ID. + CustomerID string `json:"customer_id"` + // ExpiresAt is when the access token will expire. + ExpiresAt string `json:"expires_at"` + // ID is a unique ID. + ID string `json:"id"` + // Name is a description of the token. + Name string `json:"name"` + // UserID is the user's ID. + UserID string `json:"user_id"` +} + +// AuthorizationResult represents the result of the authorization process. +type AuthorizationResult struct { + // Email address extracted from JWT claims. + Email string + // Err is any error received during authentication. + Err error + // Jwt is the JWT token returned by the authorization server. + Jwt JWT + // SessionToken is a temporary API token. + SessionToken string +} + +// JWT is the API response for a Token request. +// +// Access Token typically has a TTL of 5mins. +// Refresh Token typically has a TTL of 30mins. +type JWT struct { + // AccessToken can be exchanged for a Fastly API token. + AccessToken string `json:"access_token"` + // ExpiresIn indicates the lifetime (in seconds) of the access token. + ExpiresIn int `json:"expires_in"` + // IDToken contains user information that must be decoded and extracted. + IDToken string `json:"id_token"` + // RefreshExpiresIn indicates the lifetime (in seconds) of the refresh token. + RefreshExpiresIn int `json:"refresh_expires_in"` + // RefreshToken contains a token used to refresh the issued access token. + RefreshToken string `json:"refresh_token"` + // TokenType indicates which HTTP authentication scheme is used (e.g. Bearer). + TokenType string `json:"token_type"` +} + +// TokenExpired indicates if the specified TTL has past. +func TokenExpired(ttl int, timestamp int64) bool { + d := time.Duration(ttl) * time.Second + ttlAgo := time.Now().Add(-d).Unix() + return timestamp < ttlAgo +} + +// IsLongLivedToken identifies if profile has SSO access/refresh values set. +func IsLongLivedToken(pd *config.Profile) bool { + // If user has followed SSO flow before, then these will not be zero values. + return pd.AccessToken == "" && pd.RefreshToken == "" && pd.AccessTokenCreated == 0 && pd.RefreshTokenCreated == 0 +} diff --git a/pkg/auth/doc.go b/pkg/auth/doc.go new file mode 100644 index 000000000..80936d07b --- /dev/null +++ b/pkg/auth/doc.go @@ -0,0 +1,2 @@ +// Package auth contains types to authenticate with Fastly. +package auth diff --git a/pkg/backend/backend_test.go b/pkg/backend/backend_test.go deleted file mode 100644 index 0cfd0bb8e..000000000 --- a/pkg/backend/backend_test.go +++ /dev/null @@ -1,426 +0,0 @@ -package backend_test - -import ( - "bytes" - "errors" - "io" - "net/http" - "strings" - "testing" - - "github.com/fastly/cli/pkg/app" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/mock" - "github.com/fastly/cli/pkg/testutil" - "github.com/fastly/cli/pkg/update" - "github.com/fastly/go-fastly/v3/fastly" -) - -func TestBackendCreate(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"backend", "create", "--version", "1", "--service-id", "123", "--address", "example.com"}, - api: mock.API{CreateBackendFn: createBackendOK}, - wantError: "error parsing arguments: required flag --name not provided", - }, - { - args: []string{"backend", "create", "--service-id", "123", "--version", "1", "--address", "example.com", "--name", "www.test.com"}, - api: mock.API{CreateBackendFn: createBackendError}, - wantError: errTest.Error(), - }, - { - args: []string{"backend", "create", "--service-id", "123", "--version", "1", "--address", "127.0.0.1", "--name", "www.test.com"}, - api: mock.API{CreateBackendFn: createBackendOK}, - wantOutput: "Created backend www.test.com (service 123 version 1)", - }, - { - args: []string{"backend", "create", "--service-id", "123", "--version", "1", "--address", "127.0.0.1", "--name", "www.test.com", "--use-ssl", "--verbose"}, - api: mock.API{CreateBackendFn: createBackendOK}, - wantOutput: "Use-ssl was set but no port was specified, so default port 443 will be used", - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertStringContains(t, out.String(), testcase.wantOutput) - }) - } -} - -func TestBackendList(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"backend", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListBackendsFn: listBackendsOK}, - wantOutput: listBackendsShortOutput, - }, - { - args: []string{"backend", "list", "--service-id", "123", "--version", "1", "--verbose"}, - api: mock.API{ListBackendsFn: listBackendsOK}, - wantOutput: listBackendsVerboseOutput, - }, - { - args: []string{"backend", "list", "--service-id", "123", "--version", "1", "-v"}, - api: mock.API{ListBackendsFn: listBackendsOK}, - wantOutput: listBackendsVerboseOutput, - }, - { - args: []string{"backend", "--verbose", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListBackendsFn: listBackendsOK}, - wantOutput: listBackendsVerboseOutput, - }, - { - args: []string{"-v", "backend", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListBackendsFn: listBackendsOK}, - wantOutput: listBackendsVerboseOutput, - }, - { - args: []string{"backend", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListBackendsFn: listBackendsError}, - wantError: errTest.Error(), - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertString(t, testcase.wantOutput, out.String()) - }) - } -} - -func TestBackendDescribe(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"backend", "describe", "--service-id", "123", "--version", "1"}, - api: mock.API{GetBackendFn: getBackendOK}, - wantError: "error parsing arguments: required flag --name not provided", - }, - { - args: []string{"backend", "describe", "--service-id", "123", "--version", "1", "--name", "www.test.com"}, - api: mock.API{GetBackendFn: getBackendError}, - wantError: errTest.Error(), - }, - { - args: []string{"backend", "describe", "--service-id", "123", "--version", "1", "--name", "www.test.com"}, - api: mock.API{GetBackendFn: getBackendOK}, - wantOutput: describeBackendOutput, - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertString(t, testcase.wantOutput, out.String()) - }) - } -} - -func TestBackendUpdate(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"backend", "update", "--service-id", "123", "--version", "2", "--new-name", "www.test.com", "--comment", ""}, - api: mock.API{UpdateBackendFn: updateBackendOK}, - wantError: "error parsing arguments: required flag --name not provided", - }, - { - args: []string{"backend", "update", "--service-id", "123", "--version", "2", "--name", "www.test.com", "--new-name", "www.example.com"}, - api: mock.API{ - GetBackendFn: getBackendError, - UpdateBackendFn: updateBackendOK, - }, - wantError: errTest.Error(), - }, - { - args: []string{"backend", "update", "--service-id", "123", "--version", "1", "--name", "www.test.com", "--new-name", "www.example.com"}, - api: mock.API{ - GetBackendFn: getBackendError, - UpdateBackendFn: updateBackendError, - }, - wantError: errTest.Error(), - }, - { - args: []string{"backend", "update", "--service-id", "123", "--version", "1", "--name", "www.test.com", "--new-name", "www.example.com", "--comment", ""}, - api: mock.API{ - GetBackendFn: getBackendOK, - UpdateBackendFn: updateBackendOK, - }, - wantOutput: "Updated backend www.example.com (service 123 version 1)", - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertStringContains(t, out.String(), testcase.wantOutput) - }) - } -} - -func TestBackendDelete(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"backend", "delete", "--service-id", "123", "--version", "1"}, - api: mock.API{DeleteBackendFn: deleteBackendOK}, - wantError: "error parsing arguments: required flag --name not provided", - }, - { - args: []string{"backend", "delete", "--service-id", "123", "--version", "1", "--name", "www.test.com"}, - api: mock.API{DeleteBackendFn: deleteBackendError}, - wantError: errTest.Error(), - }, - { - args: []string{"backend", "delete", "--service-id", "123", "--version", "1", "--name", "www.test.com"}, - api: mock.API{DeleteBackendFn: deleteBackendOK}, - wantOutput: "Deleted backend www.test.com (service 123 version 1)", - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertStringContains(t, out.String(), testcase.wantOutput) - }) - } -} - -var errTest = errors.New("fixture error") - -func createBackendOK(i *fastly.CreateBackendInput) (*fastly.Backend, error) { - return &fastly.Backend{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: i.Name, - Comment: i.Comment, - }, nil -} - -func createBackendError(i *fastly.CreateBackendInput) (*fastly.Backend, error) { - return nil, errTest -} - -func listBackendsOK(i *fastly.ListBackendsInput) ([]*fastly.Backend, error) { - return []*fastly.Backend{ - { - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "test.com", - Address: "www.test.com", - Port: 80, - Comment: "test", - }, - { - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "example.com", - Address: "www.example.com", - Port: 443, - Comment: "example", - }, - }, nil -} - -func listBackendsError(i *fastly.ListBackendsInput) ([]*fastly.Backend, error) { - return nil, errTest -} - -var listBackendsShortOutput = strings.TrimSpace(` -SERVICE VERSION NAME ADDRESS PORT COMMENT -123 1 test.com www.test.com 80 test -123 1 example.com www.example.com 443 example -`) + "\n" - -var listBackendsVerboseOutput = strings.Join([]string{ - "Fastly API token not provided", - "Fastly API endpoint: https://api.fastly.com", - "Service ID: 123", - "Version: 1", - " Backend 1/2", - " Name: test.com", - " Comment: test", - " Address: www.test.com", - " Port: 80", - " Override host: ", - " Connect timeout: 0", - " Max connections: 0", - " First byte timeout: 0", - " Between bytes timeout: 0", - " Auto loadbalance: false", - " Weight: 0", - " Healthcheck: ", - " Shield: ", - " Use SSL: false", - " SSL check cert: false", - " SSL CA cert: ", - " SSL client cert: ", - " SSL client key: ", - " SSL cert hostname: ", - " SSL SNI hostname: ", - " Min TLS version: ", - " Max TLS version: ", - " SSL ciphers: []", - " Backend 2/2", - " Name: example.com", - " Comment: example", - " Address: www.example.com", - " Port: 443", - " Override host: ", - " Connect timeout: 0", - " Max connections: 0", - " First byte timeout: 0", - " Between bytes timeout: 0", - " Auto loadbalance: false", - " Weight: 0", - " Healthcheck: ", - " Shield: ", - " Use SSL: false", - " SSL check cert: false", - " SSL CA cert: ", - " SSL client cert: ", - " SSL client key: ", - " SSL cert hostname: ", - " SSL SNI hostname: ", - " Min TLS version: ", - " Max TLS version: ", - " SSL ciphers: []", -}, "\n") + "\n\n" - -func getBackendOK(i *fastly.GetBackendInput) (*fastly.Backend, error) { - return &fastly.Backend{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "test.com", - Address: "www.test.com", - Port: 80, - Comment: "test", - }, nil -} - -func getBackendError(i *fastly.GetBackendInput) (*fastly.Backend, error) { - return nil, errTest -} - -var describeBackendOutput = strings.Join([]string{ - "Service ID: 123", - "Version: 1", - "Name: test.com", - "Comment: test", - "Address: www.test.com", - "Port: 80", - "Override host: ", - "Connect timeout: 0", - "Max connections: 0", - "First byte timeout: 0", - "Between bytes timeout: 0", - "Auto loadbalance: false", - "Weight: 0", - "Healthcheck: ", - "Shield: ", - "Use SSL: false", - "SSL check cert: false", - "SSL CA cert: ", - "SSL client cert: ", - "SSL client key: ", - "SSL cert hostname: ", - "SSL SNI hostname: ", - "Min TLS version: ", - "Max TLS version: ", - "SSL ciphers: []", -}, "\n") + "\n" - -func updateBackendOK(i *fastly.UpdateBackendInput) (*fastly.Backend, error) { - return &fastly.Backend{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: *i.NewName, - Comment: *i.Comment, - }, nil -} - -func updateBackendError(i *fastly.UpdateBackendInput) (*fastly.Backend, error) { - return nil, errTest -} - -func deleteBackendOK(i *fastly.DeleteBackendInput) error { - return nil -} - -func deleteBackendError(i *fastly.DeleteBackendInput) error { - return errTest -} diff --git a/pkg/backend/create.go b/pkg/backend/create.go deleted file mode 100644 index 671569296..000000000 --- a/pkg/backend/create.go +++ /dev/null @@ -1,84 +0,0 @@ -package backend - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// CreateCommand calls the Fastly API to create backends. -type CreateCommand struct { - common.Base - Input fastly.CreateBackendInput - - // We must store all of the boolean flags separately to the input structure - // so they can be casted to go-fastly's custom `Compatibool` type later. - AutoLoadbalance bool - UseSSL bool - SSLCheckCert bool -} - -// NewCreateCommand returns a usable command registered under the parent. -func NewCreateCommand(parent common.Registerer, globals *config.Data) *CreateCommand { - var c CreateCommand - c.Globals = globals - c.CmdClause = parent.Command("create", "Create a backend on a Fastly service version").Alias("add") - - c.CmdClause.Flag("service-id", "Service ID").Short('s').Required().StringVar(&c.Input.ServiceID) - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Input.ServiceVersion) - c.CmdClause.Flag("name", "Backend name").Short('n').Required().StringVar(&c.Input.Name) - c.CmdClause.Flag("address", "A hostname, IPv4, or IPv6 address for the backend").Required().StringVar(&c.Input.Address) - - c.CmdClause.Flag("comment", "A descriptive note").StringVar(&c.Input.Comment) - c.CmdClause.Flag("port", "Port number of the address").UintVar(&c.Input.Port) - c.CmdClause.Flag("override-host", "The hostname to override the Host header").StringVar(&c.Input.OverrideHost) - c.CmdClause.Flag("connect-timeout", "How long to wait for a timeout in milliseconds").UintVar(&c.Input.ConnectTimeout) - c.CmdClause.Flag("max-conn", "Maximum number of connections").UintVar(&c.Input.MaxConn) - c.CmdClause.Flag("first-byte-timeout", "How long to wait for the first bytes in milliseconds").UintVar(&c.Input.FirstByteTimeout) - c.CmdClause.Flag("between-bytes-timeout", "How long to wait between bytes in milliseconds").UintVar(&c.Input.BetweenBytesTimeout) - c.CmdClause.Flag("auto-loadbalance", "Whether or not this backend should be automatically load balanced").BoolVar(&c.AutoLoadbalance) - c.CmdClause.Flag("weight", "Weight used to load balance this backend against others").UintVar(&c.Input.Weight) - c.CmdClause.Flag("request-condition", "Condition, which if met, will select this backend during a request").StringVar(&c.Input.RequestCondition) - c.CmdClause.Flag("healthcheck", "The name of the healthcheck to use with this backend").StringVar(&c.Input.HealthCheck) - c.CmdClause.Flag("shield", "The shield POP designated to reduce inbound load on this origin by serving the cached data to the rest of the network").StringVar(&c.Input.Shield) - c.CmdClause.Flag("use-ssl", "Whether or not to use SSL to reach the backend").BoolVar(&c.UseSSL) - c.CmdClause.Flag("ssl-check-cert", "Be strict on checking SSL certs").BoolVar(&c.SSLCheckCert) - c.CmdClause.Flag("ssl-ca-cert", "CA certificate attached to origin").StringVar(&c.Input.SSLCACert) - c.CmdClause.Flag("ssl-client-cert", "Client certificate attached to origin").StringVar(&c.Input.SSLClientCert) - c.CmdClause.Flag("ssl-client-key", "Client key attached to origin").StringVar(&c.Input.SSLClientKey) - c.CmdClause.Flag("ssl-cert-hostname", "Overrides ssl_hostname, but only for cert verification. Does not affect SNI at all.").StringVar(&c.Input.SSLCertHostname) - c.CmdClause.Flag("ssl-sni-hostname", "Overrides ssl_hostname, but only for SNI in the handshake. Does not affect cert validation at all.").StringVar(&c.Input.SSLSNIHostname) - c.CmdClause.Flag("min-tls-version", "Minimum allowed TLS version on SSL connections to this backend").StringVar(&c.Input.MinTLSVersion) - c.CmdClause.Flag("max-tls-version", "Maximum allowed TLS version on SSL connections to this backend").StringVar(&c.Input.MaxTLSVersion) - c.CmdClause.Flag("ssl-ciphers", "List of OpenSSL ciphers (see https://www.openssl.org/docs/man1.0.2/man1/ciphers for details)").StringsVar(&c.Input.SSLCiphers) - - return &c -} - -// Exec invokes the application logic for the command. -func (c *CreateCommand) Exec(in io.Reader, out io.Writer) error { - // Sadly, go-fastly uses custom a `Compatibool` type as a boolean value that - // marshalls to 0/1 instead of true/false for compatability with the API. - // Therefore, we need to cast our real flag bool to a fastly.Compatibool. - c.Input.AutoLoadbalance = fastly.Compatibool(c.AutoLoadbalance) - c.Input.UseSSL = fastly.Compatibool(c.UseSSL) - c.Input.SSLCheckCert = fastly.Compatibool(c.SSLCheckCert) - - if c.UseSSL && c.Input.Port == 0 { - if c.Globals.Flag.Verbose { - text.Warning(out, "Use-ssl was set but no port was specified, so default port 443 will be used") - c.Input.Port = 443 - } - } - - b, err := c.Globals.Client.CreateBackend(&c.Input) - if err != nil { - return err - } - - text.Success(out, "Created backend %s (service %s version %d)", b.Name, b.ServiceID, b.ServiceVersion) - return nil -} diff --git a/pkg/backend/delete.go b/pkg/backend/delete.go deleted file mode 100644 index 9ff634f0a..000000000 --- a/pkg/backend/delete.go +++ /dev/null @@ -1,37 +0,0 @@ -package backend - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// DeleteCommand calls the Fastly API to delete backends. -type DeleteCommand struct { - common.Base - Input fastly.DeleteBackendInput -} - -// NewDeleteCommand returns a usable command registered under the parent. -func NewDeleteCommand(parent common.Registerer, globals *config.Data) *DeleteCommand { - var c DeleteCommand - c.Globals = globals - c.CmdClause = parent.Command("delete", "Delete a backend on a Fastly service version").Alias("remove") - c.CmdClause.Flag("service-id", "Service ID").Short('s').Required().StringVar(&c.Input.ServiceID) - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Input.ServiceVersion) - c.CmdClause.Flag("name", "Backend name").Short('n').Required().StringVar(&c.Input.Name) - return &c -} - -// Exec invokes the application logic for the command. -func (c *DeleteCommand) Exec(in io.Reader, out io.Writer) error { - if err := c.Globals.Client.DeleteBackend(&c.Input); err != nil { - return err - } - - text.Success(out, "Deleted backend %s (service %s version %d)", c.Input.Name, c.Input.ServiceID, c.Input.ServiceVersion) - return nil -} diff --git a/pkg/backend/describe.go b/pkg/backend/describe.go deleted file mode 100644 index 195a6656b..000000000 --- a/pkg/backend/describe.go +++ /dev/null @@ -1,42 +0,0 @@ -package backend - -import ( - "fmt" - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// DescribeCommand calls the Fastly API to describe a backend. -type DescribeCommand struct { - common.Base - Input fastly.GetBackendInput -} - -// NewDescribeCommand returns a usable command registered under the parent. -func NewDescribeCommand(parent common.Registerer, globals *config.Data) *DescribeCommand { - var c DescribeCommand - c.Globals = globals - c.CmdClause = parent.Command("describe", "Show detailed information about a backend on a Fastly service version").Alias("get") - c.CmdClause.Flag("service-id", "Service ID").Short('s').Required().StringVar(&c.Input.ServiceID) - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Input.ServiceVersion) - c.CmdClause.Flag("name", "Name of backend").Short('n').Required().StringVar(&c.Input.Name) - return &c -} - -// Exec invokes the application logic for the command. -func (c *DescribeCommand) Exec(in io.Reader, out io.Writer) error { - backend, err := c.Globals.Client.GetBackend(&c.Input) - if err != nil { - return err - } - - fmt.Fprintf(out, "Service ID: %s\n", backend.ServiceID) - fmt.Fprintf(out, "Version: %d\n", backend.ServiceVersion) - text.PrintBackend(out, "", backend) - - return nil -} diff --git a/pkg/backend/list.go b/pkg/backend/list.go deleted file mode 100644 index f91d789e5..000000000 --- a/pkg/backend/list.go +++ /dev/null @@ -1,55 +0,0 @@ -package backend - -import ( - "fmt" - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// ListCommand calls the Fastly API to list backends. -type ListCommand struct { - common.Base - Input fastly.ListBackendsInput -} - -// NewListCommand returns a usable command registered under the parent. -func NewListCommand(parent common.Registerer, globals *config.Data) *ListCommand { - var c ListCommand - c.Globals = globals - c.CmdClause = parent.Command("list", "List backends on a Fastly service version") - c.CmdClause.Flag("service-id", "Service ID").Short('s').Required().StringVar(&c.Input.ServiceID) - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Input.ServiceVersion) - return &c -} - -// Exec invokes the application logic for the command. -func (c *ListCommand) Exec(in io.Reader, out io.Writer) error { - backends, err := c.Globals.Client.ListBackends(&c.Input) - if err != nil { - return err - } - - if !c.Globals.Verbose() { - tw := text.NewTable(out) - tw.AddHeader("SERVICE", "VERSION", "NAME", "ADDRESS", "PORT", "COMMENT") - for _, backend := range backends { - tw.AddLine(backend.ServiceID, backend.ServiceVersion, backend.Name, backend.Address, backend.Port, backend.Comment) - } - tw.Print() - return nil - } - - fmt.Fprintf(out, "Service ID: %s\n", c.Input.ServiceID) - fmt.Fprintf(out, "Version: %d\n", c.Input.ServiceVersion) - for i, backend := range backends { - fmt.Fprintf(out, "\tBackend %d/%d\n", i+1, len(backends)) - text.PrintBackend(out, "\t\t", backend) - } - fmt.Fprintln(out) - - return nil -} diff --git a/pkg/backend/root.go b/pkg/backend/root.go deleted file mode 100644 index 0c3babb78..000000000 --- a/pkg/backend/root.go +++ /dev/null @@ -1,28 +0,0 @@ -package backend - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/config" -) - -// RootCommand is the parent command for all subcommands in this package. -// It should be installed under the primary root command. -type RootCommand struct { - common.Base - // no flags -} - -// NewRootCommand returns a new command registered in the parent. -func NewRootCommand(parent common.Registerer, globals *config.Data) *RootCommand { - var c RootCommand - c.Globals = globals - c.CmdClause = parent.Command("backend", "Manipulate Fastly service version backends") - return &c -} - -// Exec implements the command interface. -func (c *RootCommand) Exec(in io.Reader, out io.Writer) error { - panic("unreachable") -} diff --git a/pkg/backend/update.go b/pkg/backend/update.go deleted file mode 100644 index c8854cb2b..000000000 --- a/pkg/backend/update.go +++ /dev/null @@ -1,198 +0,0 @@ -package backend - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// UpdateCommand calls the Fastly API to update backends. -type UpdateCommand struct { - common.Base - Input fastly.GetBackendInput - - NewName common.OptionalString - Comment common.OptionalString - Address common.OptionalString - Port common.OptionalUint - OverrideHost common.OptionalString - ConnectTimeout common.OptionalUint - MaxConn common.OptionalUint - FirstByteTimeout common.OptionalUint - BetweenBytesTimeout common.OptionalUint - AutoLoadbalance common.OptionalBool - Weight common.OptionalUint - RequestCondition common.OptionalString - HealthCheck common.OptionalString - Hostname common.OptionalString - Shield common.OptionalString - UseSSL common.OptionalBool - SSLCheckCert common.OptionalBool - SSLCACert common.OptionalString - SSLClientCert common.OptionalString - SSLClientKey common.OptionalString - SSLCertHostname common.OptionalString - SSLSNIHostname common.OptionalString - MinTLSVersion common.OptionalString - MaxTLSVersion common.OptionalString - SSLCiphers common.OptionalStringSlice -} - -// NewUpdateCommand returns a usable command registered under the parent. -func NewUpdateCommand(parent common.Registerer, globals *config.Data) *UpdateCommand { - var c UpdateCommand - c.Globals = globals - c.CmdClause = parent.Command("update", "Update a backend on a Fastly service version") - - c.CmdClause.Flag("service-id", "Service ID").Short('s').Required().StringVar(&c.Input.ServiceID) - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Input.ServiceVersion) - c.CmdClause.Flag("name", "backend name").Short('n').Required().StringVar(&c.Input.Name) - - c.CmdClause.Flag("new-name", "New backend name").Action(c.NewName.Set).StringVar(&c.NewName.Value) - c.CmdClause.Flag("comment", "A descriptive note").Action(c.Comment.Set).StringVar(&c.Comment.Value) - c.CmdClause.Flag("address", "A hostname, IPv4, or IPv6 address for the backend").Action(c.Address.Set).StringVar(&c.Address.Value) - c.CmdClause.Flag("port", "Port number of the address").Action(c.Port.Set).UintVar(&c.Port.Value) - c.CmdClause.Flag("override-host", "The hostname to override the Host header").Action(c.OverrideHost.Set).StringVar(&c.OverrideHost.Value) - c.CmdClause.Flag("connect-timeout", "How long to wait for a timeout in milliseconds").Action(c.ConnectTimeout.Set).UintVar(&c.ConnectTimeout.Value) - c.CmdClause.Flag("max-conn", "Maximum number of connections").Action(c.MaxConn.Set).UintVar(&c.MaxConn.Value) - c.CmdClause.Flag("first-byte-timeout", "How long to wait for the first bytes in milliseconds").Action(c.FirstByteTimeout.Set).UintVar(&c.MaxConn.Value) - c.CmdClause.Flag("between-bytes-timeout", "How long to wait between bytes in milliseconds").Action(c.BetweenBytesTimeout.Set).UintVar(&c.BetweenBytesTimeout.Value) - c.CmdClause.Flag("auto-loadbalance", "Whether or not this backend should be automatically load balanced").Action(c.AutoLoadbalance.Set).BoolVar(&c.AutoLoadbalance.Value) - c.CmdClause.Flag("weight", "Weight used to load balance this backend against others").Action(c.Weight.Set).UintVar(&c.Weight.Value) - c.CmdClause.Flag("request-condition", "condition, which if met, will select this backend during a request").Action(c.RequestCondition.Set).StringVar(&c.RequestCondition.Value) - c.CmdClause.Flag("healthcheck", "The name of the healthcheck to use with this backend").Action(c.HealthCheck.Set).StringVar(&c.HealthCheck.Value) - c.CmdClause.Flag("shield", "The shield POP designated to reduce inbound load on this origin by serving the cached data to the rest of the network").Action(c.Shield.Set).StringVar(&c.Shield.Value) - c.CmdClause.Flag("use-ssl", "Whether or not to use SSL to reach the backend").Action(c.UseSSL.Set).BoolVar(&c.UseSSL.Value) - c.CmdClause.Flag("ssl-check-cert", "Be strict on checking SSL certs").Action(c.SSLCheckCert.Set).BoolVar(&c.SSLCheckCert.Value) - c.CmdClause.Flag("ssl-ca-cert", "CA certificate attached to origin").Action(c.SSLCACert.Set).StringVar(&c.SSLCACert.Value) - c.CmdClause.Flag("ssl-client-cert", "Client certificate attached to origin").Action(c.SSLClientCert.Set).StringVar(&c.SSLClientCert.Value) - c.CmdClause.Flag("ssl-client-key", "Client key attached to origin").Action(c.SSLClientKey.Set).StringVar(&c.SSLClientKey.Value) - c.CmdClause.Flag("ssl-cert-hostname", "Overrides ssl_hostname, but only for cert verification. Does not affect SNI at all.").Action(c.SSLCertHostname.Set).StringVar(&c.SSLCertHostname.Value) - c.CmdClause.Flag("ssl-sni-hostname", "Overrides ssl_hostname, but only for SNI in the handshake. Does not affect cert validation at all.").Action(c.SSLSNIHostname.Set).StringVar(&c.SSLSNIHostname.Value) - c.CmdClause.Flag("min-tls-version", "Minimum allowed TLS version on SSL connections to this backend").Action(c.MinTLSVersion.Set).StringVar(&c.MinTLSVersion.Value) - c.CmdClause.Flag("max-tls-version", "Maximum allowed TLS version on SSL connections to this backend").Action(c.MaxTLSVersion.Set).StringVar(&c.MaxTLSVersion.Value) - c.CmdClause.Flag("ssl-ciphers", "List of OpenSSL ciphers (see https://www.openssl.org/docs/man1.0.2/man1/ciphers for details)").Action(c.SSLCiphers.Set).StringsVar(&c.SSLCiphers.Value) - - return &c -} - -// Exec invokes the application logic for the command. -func (c *UpdateCommand) Exec(in io.Reader, out io.Writer) error { - b, err := c.Globals.Client.GetBackend(&c.Input) - if err != nil { - return err - } - - input := &fastly.UpdateBackendInput{ - ServiceID: b.ServiceID, - ServiceVersion: b.ServiceVersion, - Name: b.Name, - } - - if c.NewName.WasSet { - input.NewName = fastly.String(c.NewName.Value) - } - - if c.Comment.WasSet { - input.Comment = fastly.String(c.Comment.Value) - } - - if c.Address.WasSet { - input.Address = fastly.String(c.Address.Value) - } - - if c.Port.WasSet { - input.Port = fastly.Uint(c.Port.Value) - } - - if c.OverrideHost.WasSet { - input.OverrideHost = fastly.String(c.OverrideHost.Value) - } - - if c.ConnectTimeout.WasSet { - input.ConnectTimeout = fastly.Uint(c.ConnectTimeout.Value) - } - - if c.MaxConn.WasSet { - input.MaxConn = fastly.Uint(c.MaxConn.Value) - } - - if c.FirstByteTimeout.WasSet { - input.FirstByteTimeout = fastly.Uint(c.FirstByteTimeout.Value) - } - - if c.BetweenBytesTimeout.WasSet { - input.BetweenBytesTimeout = fastly.Uint(c.BetweenBytesTimeout.Value) - } - - if c.AutoLoadbalance.WasSet { - input.AutoLoadbalance = fastly.CBool(c.AutoLoadbalance.Value) - } - - if c.Weight.WasSet { - input.Weight = fastly.Uint(c.Weight.Value) - } - - if c.RequestCondition.WasSet { - input.RequestCondition = fastly.String(c.RequestCondition.Value) - } - - if c.HealthCheck.WasSet { - input.HealthCheck = fastly.String(c.HealthCheck.Value) - } - - if c.Shield.WasSet { - input.Shield = fastly.String(c.Shield.Value) - } - - if c.UseSSL.WasSet { - input.UseSSL = fastly.CBool(c.UseSSL.Value) - } - - if c.SSLCheckCert.WasSet { - input.SSLCheckCert = fastly.CBool(c.SSLCheckCert.Value) - } - - if c.SSLCACert.WasSet { - input.SSLCACert = fastly.String(c.SSLCACert.Value) - } - - if c.SSLClientCert.WasSet { - input.SSLClientCert = fastly.String(c.SSLClientCert.Value) - } - - if c.SSLClientKey.WasSet { - input.SSLClientKey = fastly.String(c.SSLClientKey.Value) - } - - if c.SSLCertHostname.WasSet { - input.SSLCertHostname = fastly.String(c.SSLCertHostname.Value) - } - - if c.SSLSNIHostname.WasSet { - input.SSLSNIHostname = fastly.String(c.SSLSNIHostname.Value) - } - - if c.MinTLSVersion.WasSet { - input.MinTLSVersion = fastly.String(c.MinTLSVersion.Value) - } - - if c.MaxTLSVersion.WasSet { - input.MaxTLSVersion = fastly.String(c.MaxTLSVersion.Value) - } - - if c.SSLCiphers.WasSet { - input.SSLCiphers = c.SSLCiphers.Value - } - - b, err = c.Globals.Client.UpdateBackend(input) - if err != nil { - return err - } - - text.Success(out, "Updated backend %s (service %s version %d)", b.Name, b.ServiceID, b.ServiceVersion) - return nil -} diff --git a/pkg/check/check.go b/pkg/check/check.go index 3a109eb32..a3906a92f 100644 --- a/pkg/check/check.go +++ b/pkg/check/check.go @@ -1,3 +1,4 @@ +// Package check provides functions for validating installed binaries. package check import ( @@ -9,16 +10,13 @@ import ( // EXAMPLE: // dur is a string like "24h", "10m" or "5s". func Stale(lastVersionCheck string, dur string) bool { - d, err := time.ParseDuration("-" + dur) + ttl, err := time.ParseDuration(dur) if err != nil { // If there is no duration provided, then we should presume the loading of // remote configuration failed and that we should retry that operation. return true } - if t, _ := time.Parse(time.RFC3339, lastVersionCheck); !t.Before(time.Now().Add(d)) { - return false - } - - return true + lastChecked, _ := time.Parse(time.RFC3339, lastVersionCheck) + return lastChecked.Add(ttl).Before(time.Now()) } diff --git a/pkg/commands/acl/acl_test.go b/pkg/commands/acl/acl_test.go new file mode 100644 index 000000000..cadd4673f --- /dev/null +++ b/pkg/commands/acl/acl_test.go @@ -0,0 +1,399 @@ +package acl_test + +import ( + "testing" + + "github.com/fastly/go-fastly/v10/fastly" + + root "github.com/fastly/cli/pkg/commands/acl" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" +) + +func TestACLCreate(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --name flag", + Args: "--version 3", + WantError: "error reading service: no service ID found", + }, + { + Name: "validate missing --version flag", + Args: "--name foo", + WantError: "error parsing arguments: required flag --version not provided", + }, + { + Name: "validate missing --service-id flag", + Args: "--name foo --version 3", + WantError: "error reading service: no service ID found", + }, + { + Name: "validate missing --autoclone flag with 'active' service", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + }, + Args: "--name foo --service-id 123 --version 1", + WantError: "service version 1 is active", + }, + { + Name: "validate missing --autoclone flag with 'locked' service", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + }, + Args: "--name foo --service-id 123 --version 2", + WantError: "service version 2 is locked", + }, + { + Name: "validate CreateACL API error", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + CreateACLFn: func(_ *fastly.CreateACLInput) (*fastly.ACL, error) { + return nil, testutil.Err + }, + }, + Args: "--name foo --service-id 123 --version 3", + WantError: testutil.Err.Error(), + }, + { + Name: "validate CreateACL API success", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + CreateACLFn: func(i *fastly.CreateACLInput) (*fastly.ACL, error) { + return &fastly.ACL{ + ACLID: fastly.ToPointer("456"), + Name: i.Name, + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + }, nil + }, + }, + Args: "--name foo --service-id 123 --version 3", + WantOutput: "Created ACL 'foo' (id: 456, service: 123, version: 3)", + }, + { + Name: "validate --autoclone results in cloned service version", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + CreateACLFn: func(i *fastly.CreateACLInput) (*fastly.ACL, error) { + return &fastly.ACL{ + ACLID: fastly.ToPointer("456"), + Name: i.Name, + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + }, nil + }, + }, + Args: "--autoclone --name foo --service-id 123 --version 1", + WantOutput: "Created ACL 'foo' (id: 456, service: 123, version: 4)", + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, "create"}, scenarios) +} + +func TestACLDelete(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --name flag", + Args: "--version 3", + WantError: "error parsing arguments: required flag --name not provided", + }, + { + Name: "validate missing --version flag", + Args: "--name foobar", + WantError: "error parsing arguments: required flag --version not provided", + }, + { + Name: "validate missing --service-id flag", + Args: "--name foobar --version 3", + WantError: "error reading service: no service ID found", + }, + { + Name: "validate missing --autoclone flag with 'active' service", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + }, + Args: "--name foobar --service-id 123 --version 1", + WantError: "service version 1 is active", + }, + { + Name: "validate missing --autoclone flag with 'locked' service", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + }, + Args: "--name foo --service-id 123 --version 2", + WantError: "service version 2 is locked", + }, + { + Name: "validate DeleteACL API error", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + DeleteACLFn: func(_ *fastly.DeleteACLInput) error { + return testutil.Err + }, + }, + Args: "--name foobar --service-id 123 --version 3", + WantError: testutil.Err.Error(), + }, + { + Name: "validate DeleteACL API success", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + DeleteACLFn: func(_ *fastly.DeleteACLInput) error { + return nil + }, + }, + Args: "--name foobar --service-id 123 --version 3", + WantOutput: "Deleted ACL 'foobar' (service: 123, version: 3)", + }, + { + Name: "validate --autoclone results in cloned service version", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + DeleteACLFn: func(_ *fastly.DeleteACLInput) error { + return nil + }, + }, + Args: "--autoclone --name foo --service-id 123 --version 1", + WantOutput: "Deleted ACL 'foo' (service: 123, version: 4)", + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, "delete"}, scenarios) +} + +func TestACLDescribe(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --name flag", + Args: "--version 3", + WantError: "error parsing arguments: required flag --name not provided", + }, + { + Name: "validate missing --version flag", + Args: "--name foobar", + WantError: "error parsing arguments: required flag --version not provided", + }, + { + Name: "validate missing --service-id flag", + Args: "--name foobar --version 3", + WantError: "error reading service: no service ID found", + }, + { + Name: "validate GetACL API error", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + GetACLFn: func(_ *fastly.GetACLInput) (*fastly.ACL, error) { + return nil, testutil.Err + }, + }, + Args: "--name foobar --service-id 123 --version 3", + WantError: testutil.Err.Error(), + }, + { + Name: "validate GetACL API success", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + GetACLFn: getACL, + }, + Args: "--name foobar --service-id 123 --version 3", + WantOutput: "\nService ID: 123\nService Version: 3\n\nName: foobar\nID: 456\n\nCreated at: 2021-06-15 23:00:00 +0000 UTC\nUpdated at: 2021-06-15 23:00:00 +0000 UTC\nDeleted at: 2021-06-15 23:00:00 +0000 UTC\n", + }, + { + Name: "validate missing --autoclone flag is OK", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + GetACLFn: getACL, + }, + Args: "--name foobar --service-id 123 --version 1", + WantOutput: "\nService ID: 123\nService Version: 1\n\nName: foobar\nID: 456\n\nCreated at: 2021-06-15 23:00:00 +0000 UTC\nUpdated at: 2021-06-15 23:00:00 +0000 UTC\nDeleted at: 2021-06-15 23:00:00 +0000 UTC\n", + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, "describe"}, scenarios) +} + +func TestACLList(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --version flag", + WantError: "error parsing arguments: required flag --version not provided", + }, + { + Name: "validate missing --service-id flag", + Args: "--version 3", + WantError: "error reading service: no service ID found", + }, + { + Name: "validate ListACLs API error", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListACLsFn: func(_ *fastly.ListACLsInput) ([]*fastly.ACL, error) { + return nil, testutil.Err + }, + }, + Args: "--service-id 123 --version 3", + WantError: testutil.Err.Error(), + }, + { + Name: "validate ListACLs API success", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListACLsFn: listACLs, + }, + Args: "--service-id 123 --version 3", + WantOutput: "SERVICE ID VERSION NAME ID\n123 3 foo 456\n123 3 bar 789\n", + }, + { + Name: "validate missing --autoclone flag is OK", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListACLsFn: listACLs, + }, + Args: "--service-id 123 --version 1", + WantOutput: "SERVICE ID VERSION NAME ID\n123 1 foo 456\n123 1 bar 789\n", + }, + { + Name: "validate --verbose flag", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListACLsFn: listACLs, + }, + Args: "--service-id 123 --verbose --version 1", + WantOutput: "Fastly API endpoint: https://api.fastly.com\nFastly API token provided via config file (profile: user)\n\nService ID (via --service-id): 123\n\nService Version: 1\n\nName: foo\nID: 456\n\nCreated at: 2021-06-15 23:00:00 +0000 UTC\nUpdated at: 2021-06-15 23:00:00 +0000 UTC\nDeleted at: 2021-06-15 23:00:00 +0000 UTC\n\nName: bar\nID: 789\n\nCreated at: 2021-06-15 23:00:00 +0000 UTC\nUpdated at: 2021-06-15 23:00:00 +0000 UTC\nDeleted at: 2021-06-15 23:00:00 +0000 UTC\n\n", + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, "list"}, scenarios) +} + +func TestACLUpdate(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --name flag", + Args: "--new-name beepboop --version 3", + WantError: "error parsing arguments: required flag --name not provided", + }, + { + Name: "validate missing --new-name flag", + Args: "--name foobar --version 3", + WantError: "error parsing arguments: required flag --new-name not provided", + }, + { + Name: "validate missing --version flag", + Args: "--name foobar --new-name beepboop", + WantError: "error parsing arguments: required flag --version not provided", + }, + { + Name: "validate missing --service-id flag", + Args: "--name foobar --new-name beepboop --version 3", + WantError: "error reading service: no service ID found", + }, + { + Name: "validate missing --autoclone flag with 'active' service", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + }, + Args: "--name foo --new-name beepboop --service-id 123 --version 1", + WantError: "service version 1 is active", + }, + { + Name: "validate missing --autoclone flag with 'locked' service", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + }, + Args: "--name foo --new-name beepboop --service-id 123 --version 2", + WantError: "service version 2 is locked", + }, + { + Name: "validate UpdateACL API error", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + UpdateACLFn: func(_ *fastly.UpdateACLInput) (*fastly.ACL, error) { + return nil, testutil.Err + }, + }, + Args: "--name foobar --new-name beepboop --service-id 123 --version 3", + WantError: testutil.Err.Error(), + }, + { + Name: "validate UpdateACL API success", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + UpdateACLFn: func(i *fastly.UpdateACLInput) (*fastly.ACL, error) { + return &fastly.ACL{ + ACLID: fastly.ToPointer("456"), + Name: i.NewName, + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + }, nil + }, + }, + Args: "--name foobar --new-name beepboop --service-id 123 --version 3", + WantOutput: "Updated ACL 'beepboop' (previously: 'foobar', service: 123, version: 3)", + }, + { + Name: "validate --autoclone results in cloned service version", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + UpdateACLFn: func(i *fastly.UpdateACLInput) (*fastly.ACL, error) { + return &fastly.ACL{ + ACLID: fastly.ToPointer("456"), + Name: i.NewName, + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + }, nil + }, + }, + Args: "--autoclone --name foobar --new-name beepboop --service-id 123 --version 1", + WantOutput: "Updated ACL 'beepboop' (previously: 'foobar', service: 123, version: 4)", + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, "update"}, scenarios) +} + +func getACL(i *fastly.GetACLInput) (*fastly.ACL, error) { + t := testutil.Date + + return &fastly.ACL{ + ACLID: fastly.ToPointer("456"), + Name: fastly.ToPointer(i.Name), + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + + CreatedAt: &t, + DeletedAt: &t, + UpdatedAt: &t, + }, nil +} + +func listACLs(i *fastly.ListACLsInput) ([]*fastly.ACL, error) { + t := testutil.Date + vs := []*fastly.ACL{ + { + ACLID: fastly.ToPointer("456"), + Name: fastly.ToPointer("foo"), + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + + CreatedAt: &t, + DeletedAt: &t, + UpdatedAt: &t, + }, + { + ACLID: fastly.ToPointer("789"), + Name: fastly.ToPointer("bar"), + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + + CreatedAt: &t, + DeletedAt: &t, + UpdatedAt: &t, + }, + } + return vs, nil +} diff --git a/pkg/commands/acl/create.go b/pkg/commands/acl/create.go new file mode 100644 index 000000000..95b4fbb0e --- /dev/null +++ b/pkg/commands/acl/create.go @@ -0,0 +1,112 @@ +package acl + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// NewCreateCommand returns a usable command registered under the parent. +func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { + c := CreateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + + c.CmdClause = parent.Command("create", "Create a new ACL attached to the specified service version").Alias("add") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.autoClone.Set, + Dst: &c.autoClone.Value, + }) + c.CmdClause.Flag("name", "Name for the ACL. Must start with an alphanumeric character and contain only alphanumeric characters, underscores, and whitespace").Action(c.name.Set).StringVar(&c.name.Value) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + + return &c +} + +// CreateCommand calls the Fastly API to create an appropriate resource. +type CreateCommand struct { + argparser.Base + + autoClone argparser.OptionalAutoClone + name argparser.OptionalString + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion +} + +// Exec invokes the application logic for the command. +func (c *CreateCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.autoClone, + APIClient: c.Globals.APIClient, + ErrLog: c.Globals.ErrLog, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + input := c.constructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + a, err := c.Globals.APIClient.CreateACL(input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": serviceVersion.Number, + }) + return err + } + + text.Success(out, "Created ACL '%s' (id: %s, service: %s, version: %d)", fastly.ToValue(a.Name), fastly.ToValue(a.ACLID), fastly.ToValue(a.ServiceID), fastly.ToValue(a.ServiceVersion)) + return nil +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *CreateCommand) constructInput(serviceID string, serviceVersion int) *fastly.CreateACLInput { + input := fastly.CreateACLInput{ + ServiceID: serviceID, + ServiceVersion: serviceVersion, + } + if c.name.WasSet { + input.Name = &c.name.Value + } + return &input +} diff --git a/pkg/commands/acl/delete.go b/pkg/commands/acl/delete.go new file mode 100644 index 000000000..242c03895 --- /dev/null +++ b/pkg/commands/acl/delete.go @@ -0,0 +1,109 @@ +package acl + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// NewDeleteCommand returns a usable command registered under the parent. +func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { + c := DeleteCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("delete", "Delete an ACL from the specified service version").Alias("remove") + + // Required. + c.CmdClause.Flag("name", "The name of the ACL to delete").Required().StringVar(&c.name) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.autoClone.Set, + Dst: &c.autoClone.Value, + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + + return &c +} + +// DeleteCommand calls the Fastly API to delete an appropriate resource. +type DeleteCommand struct { + argparser.Base + + autoClone argparser.OptionalAutoClone + name string + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion +} + +// Exec invokes the application logic for the command. +func (c *DeleteCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.autoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + input := c.constructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + err = c.Globals.APIClient.DeleteACL(input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fastly.ToValue(serviceVersion.Number), + }) + return err + } + + text.Success(out, "Deleted ACL '%s' (service: %s, version: %d)", c.name, serviceID, fastly.ToValue(serviceVersion.Number)) + return nil +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *DeleteCommand) constructInput(serviceID string, serviceVersion int) *fastly.DeleteACLInput { + var input fastly.DeleteACLInput + + input.Name = c.name + input.ServiceID = serviceID + input.ServiceVersion = serviceVersion + + return &input +} diff --git a/pkg/commands/acl/describe.go b/pkg/commands/acl/describe.go new file mode 100644 index 000000000..be182890f --- /dev/null +++ b/pkg/commands/acl/describe.go @@ -0,0 +1,129 @@ +package acl + +import ( + "fmt" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" +) + +// NewDescribeCommand returns a usable command registered under the parent. +func NewDescribeCommand(parent argparser.Registerer, g *global.Data) *DescribeCommand { + c := DescribeCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("describe", "Retrieve a single ACL by name for the version and service").Alias("get") + + // Required. + c.CmdClause.Flag("name", "The name of the ACL").Required().StringVar(&c.name) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + + return &c +} + +// DescribeCommand calls the Fastly API to describe an appropriate resource. +type DescribeCommand struct { + argparser.Base + argparser.JSONOutput + + name string + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion +} + +// Exec invokes the application logic for the command. +func (c *DescribeCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fsterr.ServiceVersion(serviceVersion), + }) + return err + } + + input := c.constructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + + o, err := c.Globals.APIClient.GetACL(input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fastly.ToValue(serviceVersion.Number), + }) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + return c.print(out, o) +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *DescribeCommand) constructInput(serviceID string, serviceVersion int) *fastly.GetACLInput { + var input fastly.GetACLInput + + input.Name = c.name + input.ServiceID = serviceID + input.ServiceVersion = serviceVersion + + return &input +} + +// print displays the information returned from the API. +func (c *DescribeCommand) print(out io.Writer, a *fastly.ACL) error { + if !c.Globals.Verbose() { + fmt.Fprintf(out, "\nService ID: %s\n", fastly.ToValue(a.ServiceID)) + } + fmt.Fprintf(out, "Service Version: %d\n\n", fastly.ToValue(a.ServiceVersion)) + fmt.Fprintf(out, "Name: %s\n", fastly.ToValue(a.Name)) + fmt.Fprintf(out, "ID: %s\n\n", fastly.ToValue(a.ACLID)) + if a.CreatedAt != nil { + fmt.Fprintf(out, "Created at: %s\n", a.CreatedAt) + } + if a.UpdatedAt != nil { + fmt.Fprintf(out, "Updated at: %s\n", a.UpdatedAt) + } + if a.DeletedAt != nil { + fmt.Fprintf(out, "Deleted at: %s\n", a.DeletedAt) + } + return nil +} diff --git a/pkg/commands/acl/doc.go b/pkg/commands/acl/doc.go new file mode 100644 index 000000000..2990e258c --- /dev/null +++ b/pkg/commands/acl/doc.go @@ -0,0 +1,2 @@ +// Package acl contains commands to inspect and manipulate Fastly ACLs. +package acl diff --git a/pkg/commands/acl/list.go b/pkg/commands/acl/list.go new file mode 100644 index 000000000..714aaf35b --- /dev/null +++ b/pkg/commands/acl/list.go @@ -0,0 +1,155 @@ +package acl + +import ( + "fmt" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// NewListCommand returns a usable command registered under the parent. +func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { + c := ListCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("list", "List ACLs") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + + return &c +} + +// ListCommand calls the Fastly API to list appropriate resources. +type ListCommand struct { + argparser.Base + argparser.JSONOutput + + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion +} + +// Exec invokes the application logic for the command. +func (c *ListCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fsterr.ServiceVersion(serviceVersion), + }) + return err + } + + input := c.constructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + + o, err := c.Globals.APIClient.ListACLs(input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fastly.ToValue(serviceVersion.Number), + }) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + if c.Globals.Verbose() { + c.printVerbose(out, fastly.ToValue(serviceVersion.Number), o) + } else { + err = c.printSummary(out, o) + if err != nil { + return err + } + } + return nil +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *ListCommand) constructInput(serviceID string, serviceVersion int) *fastly.ListACLsInput { + var input fastly.ListACLsInput + + input.ServiceID = serviceID + input.ServiceVersion = serviceVersion + + return &input +} + +// printVerbose displays the information returned from the API in a verbose +// format. +func (c *ListCommand) printVerbose(out io.Writer, serviceVersion int, as []*fastly.ACL) { + fmt.Fprintf(out, "Service Version: %d\n\n", serviceVersion) + + for _, a := range as { + fmt.Fprintf(out, "Name: %s\n", fastly.ToValue(a.Name)) + fmt.Fprintf(out, "ID: %s\n\n", fastly.ToValue(a.ACLID)) + + if a.CreatedAt != nil { + fmt.Fprintf(out, "Created at: %s\n", a.CreatedAt) + } + if a.UpdatedAt != nil { + fmt.Fprintf(out, "Updated at: %s\n", a.UpdatedAt) + } + if a.DeletedAt != nil { + fmt.Fprintf(out, "Deleted at: %s\n", a.DeletedAt) + } + + fmt.Fprintf(out, "\n") + } +} + +// printSummary displays the information returned from the API in a summarised +// format. +func (c *ListCommand) printSummary(out io.Writer, as []*fastly.ACL) error { + t := text.NewTable(out) + t.AddHeader("SERVICE ID", "VERSION", "NAME", "ID") + for _, a := range as { + t.AddLine( + fastly.ToValue(a.ServiceID), + fastly.ToValue(a.ServiceVersion), + fastly.ToValue(a.Name), + fastly.ToValue(a.ACLID), + ) + } + t.Print() + return nil +} diff --git a/pkg/commands/acl/root.go b/pkg/commands/acl/root.go new file mode 100644 index 000000000..588f518f0 --- /dev/null +++ b/pkg/commands/acl/root.go @@ -0,0 +1,31 @@ +package acl + +import ( + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" +) + +// RootCommand is the parent command for all subcommands in this package. +// It should be installed under the primary root command. +type RootCommand struct { + argparser.Base + // no flags +} + +// CommandName is the string to be used to invoke this command. +const CommandName = "acl" + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent argparser.Registerer, g *global.Data) *RootCommand { + var c RootCommand + c.Globals = g + c.CmdClause = parent.Command(CommandName, "Manipulate Fastly ACLs (Access Control Lists)") + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { + panic("unreachable") +} diff --git a/pkg/commands/acl/update.go b/pkg/commands/acl/update.go new file mode 100644 index 000000000..f5ffbc9b8 --- /dev/null +++ b/pkg/commands/acl/update.go @@ -0,0 +1,113 @@ +package acl + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// NewUpdateCommand returns a usable command registered under the parent. +func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { + c := UpdateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("update", "Update an ACL for a particular service and version") + + // Required. + c.CmdClause.Flag("name", "The name of the ACL to update").Required().StringVar(&c.name) + c.CmdClause.Flag("new-name", "The new name of the ACL").Required().StringVar(&c.newName) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.autoClone.Set, + Dst: &c.autoClone.Value, + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + + return &c +} + +// UpdateCommand calls the Fastly API to update an appropriate resource. +type UpdateCommand struct { + argparser.Base + + autoClone argparser.OptionalAutoClone + name string + newName string + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion +} + +// Exec invokes the application logic for the command. +func (c *UpdateCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.autoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + input := c.constructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + + a, err := c.Globals.APIClient.UpdateACL(input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": serviceVersion.Number, + }) + return err + } + + text.Success(out, "Updated ACL '%s' (previously: '%s', service: %s, version: %d)", fastly.ToValue(a.Name), input.Name, fastly.ToValue(a.ServiceID), fastly.ToValue(a.ServiceVersion)) + return nil +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *UpdateCommand) constructInput(serviceID string, serviceVersion int) *fastly.UpdateACLInput { + var input fastly.UpdateACLInput + + input.Name = c.name + input.NewName = &c.newName + input.ServiceID = serviceID + input.ServiceVersion = serviceVersion + + return &input +} diff --git a/pkg/commands/aclentry/aclentry_test.go b/pkg/commands/aclentry/aclentry_test.go new file mode 100644 index 000000000..b870d8839 --- /dev/null +++ b/pkg/commands/aclentry/aclentry_test.go @@ -0,0 +1,398 @@ +package aclentry_test + +import ( + "io" + "net/http" + "strings" + "testing" + + "github.com/fastly/go-fastly/v10/fastly" + + root "github.com/fastly/cli/pkg/commands/aclentry" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" +) + +func TestACLEntryCreate(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --acl-id flag", + Args: "--ip 127.0.0.1", + WantError: "error parsing arguments: required flag --acl-id not provided", + }, + { + Name: "validate missing --ip flag", + Args: "--acl-id 123", + WantError: "error reading service: no service ID found", + }, + { + Name: "validate missing --service-id flag", + Args: "--acl-id 123 --ip 127.0.0.1", + WantError: "error reading service: no service ID found", + }, + { + Name: "validate CreateACLEntry API error", + API: mock.API{ + CreateACLEntryFn: func(_ *fastly.CreateACLEntryInput) (*fastly.ACLEntry, error) { + return nil, testutil.Err + }, + }, + Args: "--acl-id 123 --ip 127.0.0.1 --service-id 123", + WantError: testutil.Err.Error(), + }, + { + Name: "validate CreateACLEntry API success", + API: mock.API{ + CreateACLEntryFn: func(i *fastly.CreateACLEntryInput) (*fastly.ACLEntry, error) { + return &fastly.ACLEntry{ + ACLID: fastly.ToPointer(i.ACLID), + EntryID: fastly.ToPointer("456"), + IP: i.IP, + ServiceID: fastly.ToPointer(i.ServiceID), + }, nil + }, + }, + Args: "--acl-id 123 --ip 127.0.0.1 --service-id 123", + WantOutput: "Created ACL entry '456' (ip: 127.0.0.1, negated: false, service: 123)", + }, + { + Name: "validate CreateACLEntry API success with negated IP", + API: mock.API{ + CreateACLEntryFn: func(i *fastly.CreateACLEntryInput) (*fastly.ACLEntry, error) { + return &fastly.ACLEntry{ + ACLID: fastly.ToPointer(i.ACLID), + EntryID: fastly.ToPointer("456"), + IP: i.IP, + ServiceID: fastly.ToPointer(i.ServiceID), + Negated: fastly.ToPointer(bool(fastly.ToValue(i.Negated))), + }, nil + }, + }, + Args: "--acl-id 123 --ip 127.0.0.1 --negated --service-id 123", + WantOutput: "Created ACL entry '456' (ip: 127.0.0.1, negated: true, service: 123)", + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, "create"}, scenarios) +} + +func TestACLEntryDelete(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --acl-id flag", + Args: "--id 456", + WantError: "error parsing arguments: required flag --acl-id not provided", + }, + { + Name: "validate missing --id flag", + Args: "--acl-id 123", + WantError: "error parsing arguments: required flag --id not provided", + }, + { + Name: "validate missing --service-id flag", + Args: "--acl-id 123 --id 456", + WantError: "error reading service: no service ID found", + }, + { + Name: "validate DeleteACL API error", + API: mock.API{ + DeleteACLEntryFn: func(_ *fastly.DeleteACLEntryInput) error { + return testutil.Err + }, + }, + Args: "--acl-id 123 --id 456 --service-id 123", + WantError: testutil.Err.Error(), + }, + { + Name: "validate DeleteACL API success", + API: mock.API{ + DeleteACLEntryFn: func(_ *fastly.DeleteACLEntryInput) error { + return nil + }, + }, + Args: "--acl-id 123 --id 456 --service-id 123", + WantOutput: "Deleted ACL entry '456' (service: 123)", + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, "delete"}, scenarios) +} + +func TestACLEntryDescribe(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --acl-id flag", + Args: "--id 456", + WantError: "error parsing arguments: required flag --acl-id not provided", + }, + { + Name: "validate missing --id flag", + Args: "--acl-id 123", + WantError: "error parsing arguments: required flag --id not provided", + }, + { + Name: "validate missing --service-id flag", + Args: "--acl-id 123 --id 456", + WantError: "error reading service: no service ID found", + }, + { + Name: "validate GetACL API error", + API: mock.API{ + GetACLEntryFn: func(_ *fastly.GetACLEntryInput) (*fastly.ACLEntry, error) { + return nil, testutil.Err + }, + }, + Args: "--acl-id 123 --id 456 --service-id 123", + WantError: testutil.Err.Error(), + }, + { + Name: "validate GetACL API success", + API: mock.API{ + GetACLEntryFn: getACLEntry, + }, + Args: "--acl-id 123 --id 456 --service-id 123", + WantOutput: "\nService ID: 123\nACL ID: 123\nID: 456\nIP: 127.0.0.1\nSubnet: 0\nNegated: false\nComment: \n\nCreated at: 2021-06-15 23:00:00 +0000 UTC\nUpdated at: 2021-06-15 23:00:00 +0000 UTC\nDeleted at: 2021-06-15 23:00:00 +0000 UTC\n", + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, "describe"}, scenarios) +} + +func TestACLEntryList(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --acl-id flag", + WantError: "error parsing arguments: required flag --acl-id not provided", + }, + { + Name: "validate missing --service-id flag", + Args: "--acl-id 123", + WantError: "error reading service: no service ID found", + }, + { + Name: "validate ListACLEntries API error (via GetNext() call)", + API: mock.API{ + GetACLEntriesFn: func(_ *fastly.GetACLEntriesInput) *fastly.ListPaginator[fastly.ACLEntry] { + return fastly.NewPaginator[fastly.ACLEntry](&mock.HTTPClient{ + Errors: []error{ + testutil.Err, + }, + Responses: []*http.Response{nil}, + }, fastly.ListOpts{}, "/example") + }, + }, + Args: "--acl-id 123 --service-id 123", + WantError: testutil.Err.Error(), + }, + { + Name: "validate ListACLEntries API success", + API: mock.API{ + GetACLEntriesFn: func(_ *fastly.GetACLEntriesInput) *fastly.ListPaginator[fastly.ACLEntry] { + return fastly.NewPaginator[fastly.ACLEntry](&mock.HTTPClient{ + Errors: []error{nil}, + Responses: []*http.Response{ + { + Body: io.NopCloser(strings.NewReader(`[ + { + "id": "456", + "service_id": "123", + "acl_id": "xyz", + "ip": "127.0.0.1", + "negated": 0, + "subnet": 0, + "comment": "", + "created_at": "2020-04-21T18:14:32+00:00", + "updated_at": "2020-04-21T18:14:32+00:00", + "deleted_at": null + }, + { + "id": "789", + "service_id": "123", + "acl_id": "xyz", + "ip": "127.0.0.2", + "negated": 1, + "subnet": 0, + "comment": "", + "created_at": "2020-04-21T18:14:32+00:00", + "updated_at": "2020-04-21T18:14:32+00:00", + "deleted_at": null + } + ]`)), + }, + }, + }, fastly.ListOpts{}, "/example") + }, + }, + Args: "--acl-id 123 --service-id 123", + WantOutput: listACLEntriesOutput, + }, + { + Name: "validate --verbose flag", + API: mock.API{ + GetACLEntriesFn: func(_ *fastly.GetACLEntriesInput) *fastly.ListPaginator[fastly.ACLEntry] { + return fastly.NewPaginator[fastly.ACLEntry](&mock.HTTPClient{ + Errors: []error{nil}, + Responses: []*http.Response{ + { + Body: io.NopCloser(strings.NewReader(`[ + { + "id": "456", + "service_id": "123", + "acl_id": "123", + "ip": "127.0.0.1", + "negated": 0, + "subnet": 0, + "comment": "foo", + "created_at": "2021-06-15T23:00:00Z", + "updated_at": "2021-06-15T23:00:00Z", + "deleted_at": "2021-06-15T23:00:00Z" + }, + { + "id": "789", + "service_id": "123", + "acl_id": "123", + "ip": "127.0.0.2", + "negated": 1, + "subnet": 0, + "comment": "bar", + "created_at": "2021-06-15T23:00:00Z", + "updated_at": "2021-06-15T23:00:00Z", + "deleted_at": "2021-06-15T23:00:00Z" + } + ]`)), + }, + }, + }, fastly.ListOpts{}, "/example") + }, + }, + Args: "--acl-id 123 --per-page 1 --service-id 123 --verbose", + WantOutput: listACLEntriesOutputVerbose, + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, "list"}, scenarios) +} + +var listACLEntriesOutput = `SERVICE ID ID IP SUBNET NEGATED +123 456 127.0.0.1 0 false +123 789 127.0.0.2 0 true +` + +var listACLEntriesOutputVerbose = `Fastly API endpoint: https://api.fastly.com +Fastly API token provided via config file (profile: user) + +Service ID (via --service-id): 123 + +ACL ID: 123 +ID: 456 +IP: 127.0.0.1 +Subnet: 0 +Negated: false +Comment: foo + +Created at: 2021-06-15 23:00:00 +0000 UTC +Updated at: 2021-06-15 23:00:00 +0000 UTC +Deleted at: 2021-06-15 23:00:00 +0000 UTC + +ACL ID: 123 +ID: 789 +IP: 127.0.0.2 +Subnet: 0 +Negated: true +Comment: bar + +Created at: 2021-06-15 23:00:00 +0000 UTC +Updated at: 2021-06-15 23:00:00 +0000 UTC +Deleted at: 2021-06-15 23:00:00 +0000 UTC + +` + +func TestACLEntryUpdate(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --acl-id flag", + WantError: "error parsing arguments: required flag --acl-id not provided", + }, + { + Name: "validate missing --service-id flag", + Args: "--acl-id 123", + WantError: "error reading service: no service ID found", + }, + { + Name: "validate missing --id flag for single entry update", + Args: "--acl-id 123 --service-id 123", + WantError: "no ID found", + }, + { + Name: "validate UpdateACL API error", + API: mock.API{ + UpdateACLEntryFn: func(_ *fastly.UpdateACLEntryInput) (*fastly.ACLEntry, error) { + return nil, testutil.Err + }, + }, + Args: "--acl-id 123 --id 456 --service-id 123", + WantError: testutil.Err.Error(), + }, + { + Name: "validate error from --file set with invalid json", + API: mock.API{ + BatchModifyACLEntriesFn: func(_ *fastly.BatchModifyACLEntriesInput) error { + return nil + }, + }, + Args: `--acl-id 123 --file {"foo":"bar"} --id 456 --service-id 123`, + WantError: "missing 'entries'", + }, + { + Name: "validate error from --file set with zero json entries", + API: mock.API{ + BatchModifyACLEntriesFn: func(_ *fastly.BatchModifyACLEntriesInput) error { + return nil + }, + }, + Args: `--acl-id 123 --file {"entries":[]} --id 456 --service-id 123`, + WantError: "missing 'entries'", + }, + { + Name: "validate success with --file", + API: mock.API{ + BatchModifyACLEntriesFn: func(_ *fastly.BatchModifyACLEntriesInput) error { + return nil + }, + }, + Args: "--acl-id 123 --file testdata/batch.json --id 456 --service-id 123", + WantOutput: "Updated 3 ACL entries (service: 123)", + }, + // NOTE: When specifying JSON inline be sure not to have any spaces, and don't + // try to side-step it by wrapping in single quotes as the CLI parser will + // get confused (it will consider the single quotes as being part of the + // string it has parsed, e.g. "'{...}'" which means a json.Unmarshal error). + { + Name: "validate success with --file as inline json", + API: mock.API{ + BatchModifyACLEntriesFn: func(_ *fastly.BatchModifyACLEntriesInput) error { + return nil + }, + }, + Args: `--acl-id 123 --file {"entries":[{"op":"create","ip":"127.0.0.1","subnet":8},{"op":"update"},{"op":"upsert"}]} --id 456 --service-id 123`, + WantOutput: "Updated 3 ACL entries (service: 123)", + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, "update"}, scenarios) +} + +func getACLEntry(i *fastly.GetACLEntryInput) (*fastly.ACLEntry, error) { + t := testutil.Date + + return &fastly.ACLEntry{ + ACLID: fastly.ToPointer(i.ACLID), + EntryID: fastly.ToPointer(i.EntryID), + IP: fastly.ToPointer("127.0.0.1"), + ServiceID: fastly.ToPointer(i.ServiceID), + + CreatedAt: &t, + DeletedAt: &t, + UpdatedAt: &t, + }, nil +} diff --git a/pkg/commands/aclentry/create.go b/pkg/commands/aclentry/create.go new file mode 100644 index 000000000..5805587c3 --- /dev/null +++ b/pkg/commands/aclentry/create.go @@ -0,0 +1,102 @@ +package aclentry + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// NewCreateCommand returns a usable command registered under the parent. +func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { + c := CreateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("create", "Add an ACL entry to an ACL").Alias("add") + + // Required. + c.CmdClause.Flag("acl-id", "Alphanumeric string identifying a ACL").Required().StringVar(&c.aclID) + + // Optional. + c.CmdClause.Flag("comment", "A freeform descriptive note").Action(c.comment.Set).StringVar(&c.comment.Value) + c.CmdClause.Flag("ip", "An IP address").Action(c.ip.Set).StringVar(&c.ip.Value) + c.CmdClause.Flag("negated", "Whether to negate the match").Action(c.negated.Set).BoolVar(&c.negated.Value) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + c.CmdClause.Flag("subnet", "Number of bits for the subnet mask applied to the IP address").Action(c.subnet.Set).IntVar(&c.subnet.Value) + + return &c +} + +// CreateCommand calls the Fastly API to create an appropriate resource. +type CreateCommand struct { + argparser.Base + + aclID string + comment argparser.OptionalString + ip argparser.OptionalString + negated argparser.OptionalBool + serviceName argparser.OptionalServiceNameID + subnet argparser.OptionalInt +} + +// Exec invokes the application logic for the command. +func (c *CreateCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, source, flag, err := argparser.ServiceID(c.serviceName, *c.Globals.Manifest, c.Globals.APIClient, c.Globals.ErrLog) + if err != nil { + return err + } + if c.Globals.Verbose() { + argparser.DisplayServiceID(serviceID, flag, source, out) + } + + input := c.constructInput(serviceID) + + a, err := c.Globals.APIClient.CreateACLEntry(input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + }) + return err + } + + text.Success(out, "Created ACL entry '%s' (ip: %s, negated: %t, service: %s)", fastly.ToValue(a.EntryID), fastly.ToValue(a.IP), fastly.ToValue(a.Negated), fastly.ToValue(a.ServiceID)) + return nil +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *CreateCommand) constructInput(serviceID string) *fastly.CreateACLEntryInput { + input := fastly.CreateACLEntryInput{ + ACLID: c.aclID, + ServiceID: serviceID, + } + if c.ip.WasSet { + input.IP = &c.ip.Value + } + if c.comment.WasSet { + input.Comment = &c.comment.Value + } + if c.negated.WasSet { + input.Negated = fastly.ToPointer(fastly.Compatibool(c.negated.Value)) + } + if c.subnet.WasSet { + input.Subnet = &c.subnet.Value + } + + return &input +} diff --git a/pkg/commands/aclentry/delete.go b/pkg/commands/aclentry/delete.go new file mode 100644 index 000000000..42c01eda5 --- /dev/null +++ b/pkg/commands/aclentry/delete.go @@ -0,0 +1,84 @@ +package aclentry + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// NewDeleteCommand returns a usable command registered under the parent. +func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { + c := DeleteCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("delete", "Delete an ACL entry from a specified ACL").Alias("remove") + + // Required. + c.CmdClause.Flag("acl-id", "Alphanumeric string identifying a ACL").Required().StringVar(&c.aclID) + c.CmdClause.Flag("id", "Alphanumeric string identifying an ACL Entry").Required().StringVar(&c.id) + + // Optional. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + + return &c +} + +// DeleteCommand calls the Fastly API to delete an appropriate resource. +type DeleteCommand struct { + argparser.Base + + aclID string + id string + serviceName argparser.OptionalServiceNameID +} + +// Exec invokes the application logic for the command. +func (c *DeleteCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, source, flag, err := argparser.ServiceID(c.serviceName, *c.Globals.Manifest, c.Globals.APIClient, c.Globals.ErrLog) + if err != nil { + return err + } + if c.Globals.Verbose() { + argparser.DisplayServiceID(serviceID, flag, source, out) + } + + input := c.constructInput(serviceID) + err = c.Globals.APIClient.DeleteACLEntry(input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + }) + return err + } + + text.Success(out, "Deleted ACL entry '%s' (service: %s)", input.EntryID, serviceID) + return nil +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *DeleteCommand) constructInput(serviceID string) *fastly.DeleteACLEntryInput { + var input fastly.DeleteACLEntryInput + + input.ACLID = c.aclID + input.EntryID = c.id + input.ServiceID = serviceID + + return &input +} diff --git a/pkg/commands/aclentry/describe.go b/pkg/commands/aclentry/describe.go new file mode 100644 index 000000000..42e832ce7 --- /dev/null +++ b/pkg/commands/aclentry/describe.go @@ -0,0 +1,119 @@ +package aclentry + +import ( + "fmt" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" +) + +// NewDescribeCommand returns a usable command registered under the parent. +func NewDescribeCommand(parent argparser.Registerer, g *global.Data) *DescribeCommand { + c := DescribeCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("describe", "Retrieve a single ACL entry").Alias("get") + + // Required. + c.CmdClause.Flag("acl-id", "Alphanumeric string identifying a ACL").Required().StringVar(&c.aclID) + c.CmdClause.Flag("id", "Alphanumeric string identifying an ACL Entry").Required().StringVar(&c.id) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + + return &c +} + +// DescribeCommand calls the Fastly API to describe an appropriate resource. +type DescribeCommand struct { + argparser.Base + argparser.JSONOutput + + aclID string + id string + serviceName argparser.OptionalServiceNameID +} + +// Exec invokes the application logic for the command. +func (c *DescribeCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + serviceID, source, flag, err := argparser.ServiceID(c.serviceName, *c.Globals.Manifest, c.Globals.APIClient, c.Globals.ErrLog) + if err != nil { + return err + } + if c.Globals.Verbose() { + argparser.DisplayServiceID(serviceID, flag, source, out) + } + + input := c.constructInput(serviceID) + + o, err := c.Globals.APIClient.GetACLEntry(input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + }) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + return c.print(out, o) +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *DescribeCommand) constructInput(serviceID string) *fastly.GetACLEntryInput { + var input fastly.GetACLEntryInput + + input.ACLID = c.aclID + input.EntryID = c.id + input.ServiceID = serviceID + + return &input +} + +// print displays the information returned from the API. +func (c *DescribeCommand) print(out io.Writer, a *fastly.ACLEntry) error { + if !c.Globals.Verbose() { + fmt.Fprintf(out, "\nService ID: %s\n", fastly.ToValue(a.ServiceID)) + } + fmt.Fprintf(out, "ACL ID: %s\n", fastly.ToValue(a.ACLID)) + fmt.Fprintf(out, "ID: %s\n", fastly.ToValue(a.EntryID)) + fmt.Fprintf(out, "IP: %s\n", fastly.ToValue(a.IP)) + fmt.Fprintf(out, "Subnet: %d\n", fastly.ToValue(a.Subnet)) + fmt.Fprintf(out, "Negated: %t\n", fastly.ToValue(a.Negated)) + fmt.Fprintf(out, "Comment: %s\n\n", fastly.ToValue(a.Comment)) + + if a.CreatedAt != nil { + fmt.Fprintf(out, "Created at: %s\n", a.CreatedAt) + } + if a.UpdatedAt != nil { + fmt.Fprintf(out, "Updated at: %s\n", a.UpdatedAt) + } + if a.DeletedAt != nil { + fmt.Fprintf(out, "Deleted at: %s\n", a.DeletedAt) + } + return nil +} diff --git a/pkg/commands/aclentry/doc.go b/pkg/commands/aclentry/doc.go new file mode 100644 index 000000000..1c40c9dc9 --- /dev/null +++ b/pkg/commands/aclentry/doc.go @@ -0,0 +1,3 @@ +// Package aclentry contains commands to inspect and manipulate Fastly ACL +// entries. +package aclentry diff --git a/pkg/commands/aclentry/list.go b/pkg/commands/aclentry/list.go new file mode 100644 index 000000000..92251622e --- /dev/null +++ b/pkg/commands/aclentry/list.go @@ -0,0 +1,176 @@ +package aclentry + +import ( + "fmt" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// NewListCommand returns a usable command registered under the parent. +func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { + c := ListCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("list", "List ACLs") + + // Required. + c.CmdClause.Flag("acl-id", "Alphanumeric string identifying a ACL").Required().StringVar(&c.aclID) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + + c.CmdClause.Flag("direction", "Direction in which to sort results").Default(argparser.PaginationDirection[0]).HintOptions(argparser.PaginationDirection...).EnumVar(&c.direction, argparser.PaginationDirection...) + c.CmdClause.Flag("page", "Page number of data set to fetch").IntVar(&c.page) + c.CmdClause.Flag("per-page", "Number of records per page").IntVar(&c.perPage) + c.CmdClause.Flag("sort", "Field on which to sort").Default("created").StringVar(&c.sort) + + return &c +} + +// ListCommand calls the Fastly API to list appropriate resources. +type ListCommand struct { + argparser.Base + argparser.JSONOutput + + aclID string + direction string + page int + perPage int + serviceName argparser.OptionalServiceNameID + sort string +} + +// Exec invokes the application logic for the command. +func (c *ListCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + serviceID, source, flag, err := argparser.ServiceID(c.serviceName, *c.Globals.Manifest, c.Globals.APIClient, c.Globals.ErrLog) + if err != nil { + return err + } + if c.Globals.Verbose() { + argparser.DisplayServiceID(serviceID, flag, source, out) + } + + input := c.constructInput(serviceID) + paginator := c.Globals.APIClient.GetACLEntries(input) + + var o []*fastly.ACLEntry + for paginator.HasNext() { + data, err := paginator.GetNext() + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "ACL ID": c.aclID, + "Service ID": serviceID, + "Remaining Pages": paginator.Remaining(), + }) + return err + } + o = append(o, data...) + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + if c.Globals.Verbose() { + c.printVerbose(out, o) + } else { + err = c.printSummary(out, o) + if err != nil { + return err + } + } + return nil +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *ListCommand) constructInput(serviceID string) *fastly.GetACLEntriesInput { + var input fastly.GetACLEntriesInput + + input.ACLID = c.aclID + if c.direction != "" { + input.Direction = fastly.ToPointer(c.direction) + } + if c.page > 0 { + input.Page = fastly.ToPointer(c.page) + } + if c.perPage > 0 { + input.PerPage = fastly.ToPointer(c.perPage) + } + input.ServiceID = serviceID + if c.sort != "" { + input.Sort = fastly.ToPointer(c.sort) + } + + return &input +} + +// printVerbose displays the information returned from the API in a verbose +// format. +func (c *ListCommand) printVerbose(out io.Writer, as []*fastly.ACLEntry) { + for _, a := range as { + fmt.Fprintf(out, "ACL ID: %s\n", fastly.ToValue(a.ACLID)) + fmt.Fprintf(out, "ID: %s\n", fastly.ToValue(a.EntryID)) + fmt.Fprintf(out, "IP: %s\n", fastly.ToValue(a.IP)) + fmt.Fprintf(out, "Subnet: %d\n", fastly.ToValue(a.Subnet)) + fmt.Fprintf(out, "Negated: %t\n", fastly.ToValue(a.Negated)) + fmt.Fprintf(out, "Comment: %s\n\n", fastly.ToValue(a.Comment)) + + if a.CreatedAt != nil { + fmt.Fprintf(out, "Created at: %s\n", a.CreatedAt) + } + if a.UpdatedAt != nil { + fmt.Fprintf(out, "Updated at: %s\n", a.UpdatedAt) + } + if a.DeletedAt != nil { + fmt.Fprintf(out, "Deleted at: %s\n", a.DeletedAt) + } + + fmt.Fprintf(out, "\n") + } +} + +// printSummary displays the information returned from the API in a summarised +// format. +func (c *ListCommand) printSummary(out io.Writer, as []*fastly.ACLEntry) error { + t := text.NewTable(out) + t.AddHeader("SERVICE ID", "ID", "IP", "SUBNET", "NEGATED") + for _, a := range as { + var subnet int + if a.Subnet != nil { + subnet = *a.Subnet + } + t.AddLine( + fastly.ToValue(a.ServiceID), + fastly.ToValue(a.EntryID), + fastly.ToValue(a.IP), + subnet, + fastly.ToValue(a.Negated), + ) + } + t.Print() + return nil +} diff --git a/pkg/commands/aclentry/root.go b/pkg/commands/aclentry/root.go new file mode 100644 index 000000000..c02c8365e --- /dev/null +++ b/pkg/commands/aclentry/root.go @@ -0,0 +1,31 @@ +package aclentry + +import ( + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" +) + +// RootCommand is the parent command for all subcommands in this package. +// It should be installed under the primary root command. +type RootCommand struct { + argparser.Base + // no flags +} + +// CommandName is the string to be used to invoke this command. +const CommandName = "acl-entry" + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent argparser.Registerer, g *global.Data) *RootCommand { + var c RootCommand + c.Globals = g + c.CmdClause = parent.Command(CommandName, "Manipulate Fastly ACL (Access Control List) entries") + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { + panic("unreachable") +} diff --git a/pkg/commands/aclentry/testdata/batch.json b/pkg/commands/aclentry/testdata/batch.json new file mode 100644 index 000000000..67eba6bb2 --- /dev/null +++ b/pkg/commands/aclentry/testdata/batch.json @@ -0,0 +1,19 @@ +{ + "entries": [ + { + "op": "create", + "ip": "192.168.0.1", + "subnet": 8 + }, + { + "op": "update", + "id": "6yxNzlOpW1V7JfSwvLGtOc", + "ip": "192.168.0.2", + "subnet": 16 + }, + { + "op": "delete", + "id": "6yxNzlOpW1V7JfSwvLGtOc" + } + ] +} diff --git a/pkg/commands/aclentry/update.go b/pkg/commands/aclentry/update.go new file mode 100644 index 000000000..bdabd8206 --- /dev/null +++ b/pkg/commands/aclentry/update.go @@ -0,0 +1,168 @@ +package aclentry + +import ( + "encoding/json" + "fmt" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// NewUpdateCommand returns a usable command registered under the parent. +func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { + c := UpdateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("update", "Update an ACL entry for a specified ACL") + + // Required. + c.CmdClause.Flag("acl-id", "Alphanumeric string identifying a ACL").Required().StringVar(&c.aclID) + + // Optional. + c.CmdClause.Flag("comment", "A freeform descriptive note").Action(c.comment.Set).StringVar(&c.comment.Value) + c.CmdClause.Flag("file", "Batch update json passed as file path or content, e.g. $(< batch.json)").Action(c.file.Set).StringVar(&c.file.Value) + c.CmdClause.Flag("id", "Alphanumeric string identifying an ACL Entry").Action(c.id.Set).StringVar(&c.id.Value) + c.CmdClause.Flag("ip", "An IP address").Action(c.ip.Set).StringVar(&c.ip.Value) + c.CmdClause.Flag("negated", "Whether to negate the match").Action(c.negated.Set).BoolVar(&c.negated.Value) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + c.CmdClause.Flag("subnet", "Number of bits for the subnet mask applied to the IP address").Action(c.subnet.Set).IntVar(&c.subnet.Value) + + return &c +} + +// UpdateCommand calls the Fastly API to update an appropriate resource. +type UpdateCommand struct { + argparser.Base + + aclID string + comment argparser.OptionalString + file argparser.OptionalString + id argparser.OptionalString + ip argparser.OptionalString + negated argparser.OptionalBool + serviceName argparser.OptionalServiceNameID + subnet argparser.OptionalInt +} + +// Exec invokes the application logic for the command. +func (c *UpdateCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, source, flag, err := argparser.ServiceID(c.serviceName, *c.Globals.Manifest, c.Globals.APIClient, c.Globals.ErrLog) + if err != nil { + return err + } + if c.Globals.Verbose() { + argparser.DisplayServiceID(serviceID, flag, source, out) + } + + if c.file.WasSet { + input, err := c.constructBatchInput(serviceID) + if err != nil { + return err + } + + err = c.Globals.APIClient.BatchModifyACLEntries(input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + }) + return err + } + + text.Success(out, "Updated %d ACL entries (service: %s)", len(input.Entries), serviceID) + return nil + } + + input, err := c.constructInput(serviceID) + if err != nil { + return err + } + + a, err := c.Globals.APIClient.UpdateACLEntry(input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + }) + return err + } + + text.Success(out, "Updated ACL entry '%s' (ip: %s, service: %s)", fastly.ToValue(a.EntryID), fastly.ToValue(a.IP), fastly.ToValue(a.ServiceID)) + return nil +} + +// constructBatchInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *UpdateCommand) constructBatchInput(serviceID string) (*fastly.BatchModifyACLEntriesInput, error) { + var input fastly.BatchModifyACLEntriesInput + + input.ACLID = c.aclID + input.ServiceID = serviceID + + s := argparser.Content(c.file.Value) + bs := []byte(s) + + err := json.Unmarshal(bs, &input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "File": s, + }) + return nil, err + } + + if len(input.Entries) == 0 { + err := fsterr.RemediationError{ + Inner: fmt.Errorf("missing 'entries' %s", c.file.Value), + Remediation: "Consult the API documentation for the JSON format: https://www.fastly.com/documentation/reference/api/acls/acl-entry#bulk-update-acl-entries", + } + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "File": string(bs), + }) + return nil, err + } + + return &input, nil +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *UpdateCommand) constructInput(serviceID string) (*fastly.UpdateACLEntryInput, error) { + var input fastly.UpdateACLEntryInput + + if !c.id.WasSet { + return nil, fsterr.ErrNoID + } + + input.ACLID = c.aclID + input.EntryID = c.id.Value + input.ServiceID = serviceID + + if c.comment.WasSet { + input.Comment = &c.comment.Value + } + if c.ip.WasSet { + input.IP = &c.ip.Value + } + if c.negated.WasSet { + input.Negated = fastly.ToPointer(fastly.Compatibool(c.negated.Value)) + } + if c.subnet.WasSet { + input.Subnet = &c.subnet.Value + } + + return &input, nil +} diff --git a/pkg/commands/alerts/alerts_test.go b/pkg/commands/alerts/alerts_test.go new file mode 100644 index 000000000..27ebd0e2b --- /dev/null +++ b/pkg/commands/alerts/alerts_test.go @@ -0,0 +1,521 @@ +package alerts_test + +import ( + "strings" + "testing" + "time" + + root "github.com/fastly/cli/pkg/commands/alerts" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" + "github.com/fastly/go-fastly/v10/fastly" +) + +func TestAlertsCreate(t *testing.T) { + createFlags := flagList{ + Flags: []flag{ + {Flag: "--name", Value: "name"}, + {Flag: "--description", Value: "description"}, + {Flag: "--metric", Value: "status_5xx"}, + {Flag: "--source", Value: "stats"}, + {Flag: "--type", Value: "above_threshold"}, + {Flag: "--period", Value: "5m"}, + {Flag: "--threshold", Value: "10.0"}, + }, + } + + scenarios := []testutil.CLIScenario{ + { + Name: "ok all required", + Args: createFlags.String(), + API: mock.API{CreateAlertDefinitionFn: CreateAlertDefinitionResponse}, + }, + { + Name: "no name", + Args: createFlags.Remove("--name").String(), + WantError: "error parsing arguments: required flag --name not provided", + }, + { + Name: "no description", + Args: createFlags.Remove("--description").String(), + WantError: "error parsing arguments: required flag --description not provided", + }, + { + Name: "no metric", + Args: createFlags.Remove("--metric").String(), + WantError: "error parsing arguments: required flag --metric not provided", + }, + { + Name: "no source", + Args: createFlags.Remove("--source").String(), + WantError: "error parsing arguments: required flag --source not provided", + }, + { + Name: "no type", + Args: createFlags.Remove("--type").String(), + WantError: "error parsing arguments: required flag --type not provided", + }, + { + Name: "no period", + Args: createFlags.Remove("--period").String(), + WantError: "error parsing arguments: required flag --period not provided", + }, + { + Name: "no threshold", + Args: createFlags.Remove("--threshold").String(), + WantError: "error parsing arguments: required flag --threshold not provided", + }, + { + Name: "ok optional json", + Args: createFlags.Add(flag{Flag: "--json"}).String(), + API: mock.API{CreateAlertDefinitionFn: CreateAlertDefinitionResponse}, + }, + { + Name: "ok optional ignoreBelow", + Args: createFlags.Add(flag{Flag: "--ignoreBelow", Value: "5.0"}).String(), + API: mock.API{CreateAlertDefinitionFn: CreateAlertDefinitionResponse}, + }, + { + Name: "ok optional service-id", + Args: createFlags.Add(flag{Flag: "--service-id", Value: "ABC"}).String(), + API: mock.API{CreateAlertDefinitionFn: CreateAlertDefinitionResponse}, + }, + { + Name: "ok optional dimensions", + Args: createFlags. + Change(flag{Flag: "--source", Value: "origins"}). + Add(flag{Flag: "--dimensions", Value: "fastly.com"}). + Add(flag{Flag: "--dimensions", Value: "fastly2.com"}).String(), + API: mock.API{CreateAlertDefinitionFn: CreateAlertDefinitionResponse}, + }, + { + Name: "ok optional integrations", + Args: createFlags. + Add(flag{Flag: "--integrations", Value: "ABC1"}). + Add(flag{Flag: "--integrations", Value: "ABC2"}).String(), + API: mock.API{CreateAlertDefinitionFn: CreateAlertDefinitionResponse}, + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, "create"}, scenarios) +} + +func TestAlertsUpdate(t *testing.T) { + updateFlags := flagList{ + Flags: []flag{ + {Flag: "--id", Value: "ABC"}, + {Flag: "--name", Value: "name"}, + {Flag: "--description", Value: "description"}, + {Flag: "--metric", Value: "status_5xx"}, + {Flag: "--source", Value: "stats"}, + {Flag: "--type", Value: "above_threshold"}, + {Flag: "--period", Value: "5m"}, + {Flag: "--threshold", Value: "10.0"}, + }, + } + + scenarios := []testutil.CLIScenario{ + { + Name: "ok all required", + Args: updateFlags.String(), + API: mock.API{UpdateAlertDefinitionFn: UpdateAlertDefinitionResponse}, + }, + { + Name: "no id", + Args: updateFlags.Remove("--id").String(), + WantError: "error parsing arguments: required flag --id not provided", + }, + { + Name: "no name", + Args: updateFlags.Remove("--name").String(), + WantError: "error parsing arguments: required flag --name not provided", + }, + { + Name: "no description", + Args: updateFlags.Remove("--description").String(), + WantError: "error parsing arguments: required flag --description not provided", + }, + { + Name: "no metric", + Args: updateFlags.Remove("--metric").String(), + WantError: "error parsing arguments: required flag --metric not provided", + }, + { + Name: "no source", + Args: updateFlags.Remove("--source").String(), + WantError: "error parsing arguments: required flag --source not provided", + }, + { + Name: "no type", + Args: updateFlags.Remove("--type").String(), + WantError: "error parsing arguments: required flag --type not provided", + }, + { + Name: "no period", + Args: updateFlags.Remove("--period").String(), + WantError: "error parsing arguments: required flag --period not provided", + }, + { + Name: "no threshold", + Args: updateFlags.Remove("--threshold").String(), + WantError: "error parsing arguments: required flag --threshold not provided", + }, + { + Name: "ok optional json", + Args: updateFlags.Add(flag{Flag: "--json"}).String(), + API: mock.API{UpdateAlertDefinitionFn: UpdateAlertDefinitionResponse}, + }, + { + Name: "ok optional ignoreBelow", + Args: updateFlags.Add(flag{Flag: "--ignoreBelow", Value: "5.0"}).String(), + API: mock.API{UpdateAlertDefinitionFn: UpdateAlertDefinitionResponse}, + }, + { + Name: "ok optional dimensions", + Args: updateFlags. + Change(flag{Flag: "--source", Value: "origins"}). + Add(flag{Flag: "--dimensions", Value: "fastly.com"}). + Add(flag{Flag: "--dimensions", Value: "fastly2.com"}).String(), + API: mock.API{UpdateAlertDefinitionFn: UpdateAlertDefinitionResponse}, + }, + { + Name: "ok optional integrations", + Args: updateFlags.Add(flag{Flag: "--integrations", Value: "ABC1"}).Add(flag{Flag: "--integrations", Value: "ABC2"}).String(), + API: mock.API{UpdateAlertDefinitionFn: UpdateAlertDefinitionResponse}, + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, "update"}, scenarios) +} + +func TestAlertsDelete(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "no definition id", + WantError: "error parsing arguments: required flag --id not provided", + }, + { + Name: "ok", + Args: "--id ABC", + API: mock.API{ + DeleteAlertDefinitionFn: func(_ *fastly.DeleteAlertDefinitionInput) error { + return nil + }, + }, + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, "delete"}, scenarios) +} + +func TestAlertsDescribe(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "no definition id", + WantError: "error parsing arguments: required flag --id not provided", + }, + { + Name: "ok", + Args: "--id ABC", + API: mock.API{ + GetAlertDefinitionFn: func(_ *fastly.GetAlertDefinitionInput) (*fastly.AlertDefinition, error) { + response := &mockDefinition + return response, nil + }, + }, + WantOutput: listAlertsOutput, + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, "describe"}, scenarios) +} + +func TestAlertsList(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "ok", + API: mock.API{ListAlertDefinitionsFn: ListAlertDefinitionsEmptyResponse}, + WantOutput: listAlertsEmptyOutput, + }, + { + Name: "ok verbose", + Args: "-v", + API: mock.API{ListAlertDefinitionsFn: ListAlertDefinitionsEmptyResponse}, + }, + { + Name: "ok json", + Args: "-j", + API: mock.API{ListAlertDefinitionsFn: ListAlertDefinitionsEmptyResponse}, + }, + { + Name: "ok cursor", + Args: "--cursor ABC", + API: mock.API{ListAlertDefinitionsFn: ListAlertDefinitionsEmptyResponse}, + }, + { + Name: "ok limit", + Args: "--limit 1", + API: mock.API{ListAlertDefinitionsFn: ListAlertDefinitionsEmptyResponse}, + }, + { + Name: "ok definition name", + Args: "--name test", + API: mock.API{ListAlertDefinitionsFn: ListAlertDefinitionsEmptyResponse}, + }, + { + Name: "ok sort name", + Args: "--sort name", + API: mock.API{ListAlertDefinitionsFn: ListAlertDefinitionsEmptyResponse}, + }, + { + Name: "ok sort updated_at", + Args: "--sort updated_at", + API: mock.API{ListAlertDefinitionsFn: ListAlertDefinitionsEmptyResponse}, + }, + { + Name: "ok sort created_at asc", + Args: "--sort created_at --order asc", + API: mock.API{ListAlertDefinitionsFn: ListAlertDefinitionsEmptyResponse}, + }, + { + Name: "ok sort created_at desc", + Args: "--sort created_at --order desc", + API: mock.API{ListAlertDefinitionsFn: ListAlertDefinitionsEmptyResponse}, + }, + { + Name: "ok service id", + Args: "--service-id ABC", + API: mock.API{ListAlertDefinitionsFn: ListAlertDefinitionsEmptyResponse}, + }, + { + Name: "validate ListAlerts API success", + API: mock.API{ + ListAlertDefinitionsFn: func(_ *fastly.ListAlertDefinitionsInput) (*fastly.AlertDefinitionsResponse, error) { + response := &fastly.AlertDefinitionsResponse{ + Data: []fastly.AlertDefinition{mockDefinition}, + Meta: fastly.AlertsMeta{ + Total: 1, + Limit: 100, + NextCursor: "", + Sort: "-name", + }, + } + return response, nil + }, + }, + WantOutput: listAlertsOutput, + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, "list"}, scenarios) +} + +func TestAlertsHistoryList(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "ok", + API: mock.API{ListAlertHistoryFn: ListAlertHistoryEmptyResponse}, + WantOutput: listAlertHistoryEmptyOutput, + }, + { + Name: "ok verbose", + Args: "-v", + API: mock.API{ListAlertHistoryFn: ListAlertHistoryEmptyResponse}, + }, + { + Name: "ok json", + Args: "--json", + API: mock.API{ListAlertHistoryFn: ListAlertHistoryEmptyResponse}, + }, + { + Name: "ok cursor", + Args: "--cursor ABC", + API: mock.API{ListAlertHistoryFn: ListAlertHistoryEmptyResponse}, + }, + { + Name: "ok limit", + Args: "--limit 1", + API: mock.API{ListAlertHistoryFn: ListAlertHistoryEmptyResponse}, + }, + { + Name: "ok status", + Args: "--status active", + API: mock.API{ListAlertHistoryFn: ListAlertHistoryEmptyResponse}, + }, + { + Name: "ok sort start", + Args: "--sort start", + API: mock.API{ListAlertHistoryFn: ListAlertHistoryEmptyResponse}, + }, + { + Name: "ok sort start asc", + Args: "--sort start --order asc", + API: mock.API{ListAlertHistoryFn: ListAlertHistoryEmptyResponse}, + }, + { + Name: "ok sort start desc", + Args: "--sort start --order desc", + API: mock.API{ListAlertHistoryFn: ListAlertHistoryEmptyResponse}, + }, + { + Name: "ok service id", + Args: "--service-id ABC", + API: mock.API{ListAlertHistoryFn: ListAlertHistoryEmptyResponse}, + }, + { + Name: "ok definition id", + Args: "--definition-id ABC", + API: mock.API{ListAlertHistoryFn: ListAlertHistoryEmptyResponse}, + }, + { + Name: "validate ListAlerts API success", + API: mock.API{ + ListAlertHistoryFn: func(_ *fastly.ListAlertHistoryInput) (*fastly.AlertHistoryResponse, error) { + response := &fastly.AlertHistoryResponse{ + Data: []fastly.AlertHistory{mockHistory}, + Meta: fastly.AlertsMeta{ + Total: 1, + Limit: 100, + NextCursor: "", + Sort: "-start", + }, + } + return response, nil + }, + }, + WantOutput: listAlertsHistoryOutput, + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, "history"}, scenarios) +} + +type flag struct { + Flag string + Value string +} + +func (t *flag) String() string { + if t.Value == "" { + return t.Flag + } + return strings.Join([]string{t.Flag, t.Value}, " ") +} + +type flagList struct { + Flags []flag +} + +func (t *flagList) Add(flag flag) *flagList { + newTuples := flagList{} + newTuples.Flags = append(newTuples.Flags, t.Flags...) + newTuples.Flags = append(newTuples.Flags, flag) + return &newTuples +} + +func (t *flagList) Change(flag flag) *flagList { + newTuples := flagList{} + for i := range t.Flags { + if t.Flags[i].Flag == flag.Flag { + newTuples.Flags = append(newTuples.Flags, flag) + } else { + newTuples.Flags = append(newTuples.Flags, t.Flags[i]) + } + } + return &newTuples +} + +func (t *flagList) Remove(flag string) *flagList { + newTuples := flagList{} + for i := range t.Flags { + if t.Flags[i].Flag != flag { + newTuples.Flags = append(newTuples.Flags, t.Flags[i]) + } + } + return &newTuples +} + +func (t *flagList) String() string { + var strs []string + for i := range t.Flags { + strs = append(strs, t.Flags[i].String()) + } + return strings.Join(strs, " ") +} + +var mockTime = time.Date(2024, 0o5, 0o1, 12, 0o0, 11, 0, time.UTC) + +var ListAlertDefinitionsEmptyResponse = func(_ *fastly.ListAlertDefinitionsInput) (*fastly.AlertDefinitionsResponse, error) { + response := &fastly.AlertDefinitionsResponse{ + Data: []fastly.AlertDefinition{}, + Meta: fastly.AlertsMeta{ + Total: 0, + Limit: 100, + NextCursor: "", + Sort: "-name", + }, + } + return response, nil +} + +var ListAlertHistoryEmptyResponse = func(_ *fastly.ListAlertHistoryInput) (*fastly.AlertHistoryResponse, error) { + response := &fastly.AlertHistoryResponse{ + Data: []fastly.AlertHistory{}, + Meta: fastly.AlertsMeta{ + Total: 0, + Limit: 100, + NextCursor: "", + Sort: "-start", + }, + } + return response, nil +} + +var mockDefinition = fastly.AlertDefinition{ + ID: "ABC", + Name: "name", + Description: "description", + Source: "stats", + Metric: "status_5xx", + ServiceID: "SVC", + Dimensions: map[string][]string{}, + IntegrationIDs: []string{}, + EvaluationStrategy: map[string]any{ + "type": "above_threshold", + "period": "5m", + "threshold": 10.0, + }, + UpdatedAt: mockTime, + CreatedAt: mockTime, +} + +var mockHistory = fastly.AlertHistory{ + ID: "ABC", + DefinitionID: mockDefinition.ID, + Definition: mockDefinition, + Status: "active", + Start: mockTime, + End: mockTime, +} + +var CreateAlertDefinitionResponse = func(_ *fastly.CreateAlertDefinitionInput) (*fastly.AlertDefinition, error) { + response := &mockDefinition + return response, nil +} + +var UpdateAlertDefinitionResponse = func(_ *fastly.UpdateAlertDefinitionInput) (*fastly.AlertDefinition, error) { + response := &mockDefinition + return response, nil +} + +var listAlertsEmptyOutput = `DEFINITION ID SERVICE ID NAME SOURCE METRIC TYPE THRESHOLD PERIOD` + +var listAlertsOutput = `DEFINITION ID SERVICE ID NAME SOURCE METRIC TYPE THRESHOLD PERIOD +ABC SVC name stats status_5xx above_threshold 10 5m +` + +var listAlertHistoryEmptyOutput = `HISTORY ID DEFINITION ID STATUS START END` + +var listAlertsHistoryOutput = `HISTORY ID DEFINITION ID STATUS START END +ABC ABC active 2024-05-01 12:00:11 +0000 UTC 2024-05-01 12:00:11 +0000 UTC +` diff --git a/pkg/commands/alerts/common.go b/pkg/commands/alerts/common.go new file mode 100644 index 000000000..a1d1f6ee7 --- /dev/null +++ b/pkg/commands/alerts/common.go @@ -0,0 +1,132 @@ +package alerts + +import ( + "fmt" + "io" + + "github.com/fastly/cli/pkg/text" + "github.com/fastly/go-fastly/v10/fastly" +) + +// evaluationType is a list of supported evaluation types. +var evaluationType = []string{"above_threshold", "all_above_threshold", "below_threshold", "percent_absolute", "percent_decrease", "percent_increase"} + +// evaluationPeriod is a list of supported evaluation periods. +var evaluationPeriod = []string{"2m", "3m", "5m", "15m", "30m"} + +func printDefinition(out io.Writer, indent uint, definition *fastly.AlertDefinition) { + if definition != nil { + text.Indent(out, indent, "Definition ID: %s", definition.ID) + text.Indent(out, indent, "Service ID: %s", definition.ServiceID) + text.Indent(out, indent, "Name: %s", definition.Name) + text.Indent(out, indent, "Source: %s", definition.Source) + + dimensions, ok := definition.Dimensions[definition.Source] + if ok && len(dimensions) > 0 { + text.Indent(out, indent, "Dimensions:") + for i := range dimensions { + text.Indent(out, indent+4, " %s", dimensions[i]) + } + } + + text.Indent(out, indent, "Metric: %s", definition.Metric) + + text.Indent(out, indent, "Evaluation Strategy:") + eType, _ := definition.EvaluationStrategy["type"].(string) + text.Indent(out, indent+4, " Type: %s", eType) + + period, _ := definition.EvaluationStrategy["period"].(string) + text.Indent(out, indent+4, " Period: %s", period) + + threshold, _ := definition.EvaluationStrategy["threshold"].(float64) + text.Indent(out, indent+4, " Threshold: %v", threshold) + + if ignoreBelow, ok := definition.EvaluationStrategy["ignore_below"].(float64); ok { + text.Indent(out, indent+4, " IgnoreBelow: %v", ignoreBelow) + } + + integrations := definition.IntegrationIDs + if len(integrations) > 0 { + text.Indent(out, indent, "Integrations:") + for i := range integrations { + text.Indent(out, indent, " %s", integrations[i]) + } + } + + text.Indent(out, indent, "Created at: %s", definition.CreatedAt) + text.Indent(out, indent, "Updated at: %s", definition.UpdatedAt) + } +} + +// printSummary displays the information returned from the API in a summarised +// format. +func printSummary(out io.Writer, as []*fastly.AlertDefinition) { + t := text.NewTable(out) + t.AddHeader("DEFINITION ID", "SERVICE ID", "NAME", "SOURCE", "METRIC", "TYPE", "THRESHOLD", "PERIOD") + for _, a := range as { + eType, _ := a.EvaluationStrategy["type"].(string) + period, _ := a.EvaluationStrategy["period"].(string) + threshold, _ := a.EvaluationStrategy["threshold"].(float64) + t.AddLine( + a.ID, + a.ServiceID, + a.Name, + a.Source, + a.Metric, + eType, + threshold, + period, + ) + } + t.Print() +} + +// printVerbose displays the information returned from the API in a verbose +// format. +func printVerbose(out io.Writer, as []*fastly.AlertDefinition) { + for _, a := range as { + printDefinition(out, 0, a) + fmt.Fprintf(out, "\n") + } +} + +func printHistory(out io.Writer, history *fastly.AlertHistory) { + if history != nil { + start := history.Start.UTC().String() + end := history.End.UTC().String() + fmt.Fprintf(out, "History ID: %s\n", history.ID) + fmt.Fprintf(out, "Definition:\n") + printDefinition(out, 4, &history.Definition) + fmt.Fprintf(out, "Status: %s\n", history.Status) + fmt.Fprintf(out, "Start: %s\n", start) + fmt.Fprintf(out, "End: %s\n", end) + fmt.Fprintf(out, "\n") + } +} + +// printSummary displays the information returned from the API in a summarised +// format. +func printHistorySummary(out io.Writer, as []*fastly.AlertHistory) { + t := text.NewTable(out) + t.AddHeader("HISTORY ID", "DEFINITION ID", "STATUS", "START", "END") + for _, a := range as { + start := a.Start.UTC().String() + end := a.End.UTC().String() + t.AddLine( + a.ID, + a.DefinitionID, + a.Status, + start, + end, + ) + } + t.Print() +} + +// printVerbose displays the information returned from the API in a verbose +// format. +func printHistoryVerbose(out io.Writer, history []*fastly.AlertHistory) { + for _, h := range history { + printHistory(out, h) + } +} diff --git a/pkg/commands/alerts/create.go b/pkg/commands/alerts/create.go new file mode 100644 index 000000000..6e0739a19 --- /dev/null +++ b/pkg/commands/alerts/create.go @@ -0,0 +1,123 @@ +package alerts + +import ( + "io" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/go-fastly/v10/fastly" +) + +// NewCreateCommand returns a usable command registered under the parent. +func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { + c := CreateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + + c.CmdClause = parent.Command("create", "Create Alerts") + + // Required. + c.CmdClause.Flag("description", "Additional text that is included in the alert notification.").Required().StringVar(&c.description) + c.CmdClause.Flag("metric", "Metric name to alert on for a specific source.").Required().StringVar(&c.metric) + c.CmdClause.Flag("name", "Name of the alert definition.").Required().StringVar(&c.name) + c.CmdClause.Flag("period", "Period of time to evaluate whether the conditions have been met. The data is polled every minute.").Required().HintOptions(evaluationPeriod...).EnumVar(&c.period, evaluationPeriod...) + c.CmdClause.Flag("source", "Source where the metric comes from.").Required().StringVar(&c.source) + c.CmdClause.Flag("threshold", "Threshold used to alert.").Required().Float64Var(&c.threshold) + c.CmdClause.Flag("type", "Type of strategy to use to evaluate.").Required().HintOptions(evaluationType...).EnumVar(&c.eType, evaluationType...) + + // Optional. + c.CmdClause.Flag("dimensions", "Dimensions filters depending on the source type.").Action(c.dimensions.Set).StringsVar(&c.dimensions.Value) + c.CmdClause.Flag("ignoreBelow", "IgnoreBelow is the threshold for the denominator value used in evaluations that calculate a rate or ratio. Usually used to filter out noise.").Action(c.ignoreBelow.Set).Float64Var(&c.ignoreBelow.Value) + c.CmdClause.Flag("integrations", "Integrations are a list of integrations used to notify when alert fires.").Action(c.integrations.Set).StringsVar(&c.integrations.Value) + c.RegisterFlagBool(c.JSONFlag()) // --json + c.CmdClause.Flag(argparser.FlagServiceIDName, "ServiceID of the definition").Action(c.serviceID.Set).StringVar(&c.serviceID.Value) // --service-id + + return &c +} + +// CreateCommand calls the Fastly API to list appropriate resources. +type CreateCommand struct { + argparser.Base + argparser.JSONOutput + + description string + eType string + metric string + name string + period string + source string + threshold float64 + + dimensions argparser.OptionalStringSlice + ignoreBelow argparser.OptionalFloat64 + integrations argparser.OptionalStringSlice + serviceID argparser.OptionalString +} + +// Exec invokes the application logic for the command. +func (c *CreateCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + input := c.constructInput() + definition, err := c.Globals.APIClient.CreateAlertDefinition(input) + if err != nil { + return err + } + + if ok, err := c.WriteJSON(out, definition); ok { + return err + } + + definitions := []*fastly.AlertDefinition{definition} + if c.Globals.Verbose() { + printVerbose(out, definitions) + } else { + printSummary(out, definitions) + } + return nil +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *CreateCommand) constructInput() *fastly.CreateAlertDefinitionInput { + input := fastly.CreateAlertDefinitionInput{ + Description: &c.description, + EvaluationStrategy: map[string]any{ + "type": c.eType, + "period": c.period, + "threshold": c.threshold, + }, + Metric: &c.metric, + Name: &c.name, + Source: &c.source, + } + + if c.ignoreBelow.WasSet { + input.EvaluationStrategy["ignore_below"] = c.ignoreBelow.Value + } + + if c.serviceID.WasSet { + input.ServiceID = &c.serviceID.Value + } + + dimensions := map[string][]string{} + if c.source == "origins" || c.source == "domains" { + var filter []string + if c.dimensions.WasSet { + filter = c.dimensions.Value + } + dimensions[c.source] = filter + } + input.Dimensions = dimensions + + input.IntegrationIDs = []string{} + if c.integrations.WasSet { + input.IntegrationIDs = c.integrations.Value + } + + return &input +} diff --git a/pkg/commands/alerts/delete.go b/pkg/commands/alerts/delete.go new file mode 100644 index 000000000..9201fb2e9 --- /dev/null +++ b/pkg/commands/alerts/delete.go @@ -0,0 +1,66 @@ +package alerts + +import ( + "io" + + "github.com/fastly/cli/pkg/text" + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" +) + +// NewDeleteCommand returns a usable command registered under the parent. +func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { + c := DeleteCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + + c.CmdClause = parent.Command("delete", "Delete Alert") + + // Required. + c.CmdClause.Flag("id", "Alphanumeric string identifying an Alert definition").Required().StringVar(&c.definitionID) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + + return &c +} + +// DeleteCommand calls the Fastly API to delete appropriate resource. +type DeleteCommand struct { + argparser.Base + argparser.JSONOutput + + definitionID string +} + +// Exec invokes the application logic for the command. +func (c *DeleteCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + input := c.constructInput() + err := c.Globals.APIClient.DeleteAlertDefinition(input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Definition ID": c.definitionID, + }) + return err + } + + text.Success(out, "Deleted Alert entry '%s'", c.definitionID) + return nil +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *DeleteCommand) constructInput() *fastly.DeleteAlertDefinitionInput { + input := fastly.DeleteAlertDefinitionInput{ + ID: &c.definitionID, + } + return &input +} diff --git a/pkg/commands/alerts/describe.go b/pkg/commands/alerts/describe.go new file mode 100644 index 000000000..e4c2b2e17 --- /dev/null +++ b/pkg/commands/alerts/describe.go @@ -0,0 +1,71 @@ +package alerts + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" +) + +// NewDescribeCommand returns a usable command registered under the parent. +func NewDescribeCommand(parent argparser.Registerer, g *global.Data) *DescribeCommand { + c := DescribeCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + + c.CmdClause = parent.Command("describe", "Describe Alert") + + // Required. + c.CmdClause.Flag("id", "Alphanumeric string identifying an Alert definition").Required().StringVar(&c.definitionID) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + + return &c +} + +// DescribeCommand calls the Fastly API to describe appropriate resource. +type DescribeCommand struct { + argparser.Base + argparser.JSONOutput + + definitionID string +} + +// Exec invokes the application logic for the command. +func (c *DescribeCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + input := c.constructInput() + definition, err := c.Globals.APIClient.GetAlertDefinition(input) + if err != nil { + return err + } + + if ok, err := c.WriteJSON(out, definition); ok { + return err + } + + definitions := []*fastly.AlertDefinition{definition} + if c.Globals.Verbose() { + printVerbose(out, definitions) + } else { + printSummary(out, definitions) + } + return nil +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *DescribeCommand) constructInput() *fastly.GetAlertDefinitionInput { + input := fastly.GetAlertDefinitionInput{ + ID: &c.definitionID, + } + return &input +} diff --git a/pkg/commands/alerts/doc.go b/pkg/commands/alerts/doc.go new file mode 100644 index 000000000..a1dbe46a7 --- /dev/null +++ b/pkg/commands/alerts/doc.go @@ -0,0 +1,2 @@ +// Package alerts contains commands to inspect and manipulate Fastly Alerts. +package alerts diff --git a/pkg/commands/alerts/list.go b/pkg/commands/alerts/list.go new file mode 100644 index 000000000..335c2bf27 --- /dev/null +++ b/pkg/commands/alerts/list.go @@ -0,0 +1,134 @@ +package alerts + +import ( + "errors" + "io" + + "github.com/fastly/cli/pkg/text" + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" +) + +// NewListCommand returns a usable command registered under the parent. +func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { + c := ListCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("list", "List Alerts") + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + c.CmdClause.Flag("cursor", "Pagination cursor (Use 'next_cursor' value from list output)").Action(c.cursor.Set).StringVar(&c.cursor.Value) + c.CmdClause.Flag("limit", "Maximum number of items to list").Action(c.limit.Set).IntVar(&c.limit.Value) + c.CmdClause.Flag("name", "Name of the definition").Action(c.definitionName.Set).StringVar(&c.definitionName.Value) + c.CmdClause.Flag("order", "Sort by one of the following [asc, desc]").Action(c.order.Set).StringVar(&c.order.Value) + c.CmdClause.Flag("sort", "Sort by one of the following [name, created_at, updated_at]").Action(c.sort.Set).StringVar(&c.sort.Value) + c.CmdClause.Flag(argparser.FlagServiceIDName, "ServiceID of the definition").Action(c.serviceID.Set).StringVar(&c.serviceID.Value) // --service-id + + return &c +} + +// ListCommand calls the Fastly API to list appropriate resources. +type ListCommand struct { + argparser.Base + argparser.JSONOutput + + cursor argparser.OptionalString + limit argparser.OptionalInt + definitionName argparser.OptionalString + serviceID argparser.OptionalString + sort argparser.OptionalString + order argparser.OptionalString +} + +// Exec invokes the application logic for the command. +func (c *ListCommand) Exec(in io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + input, err := c.constructInput() + if err != nil { + return err + } + + for { + definitions, err := c.Globals.APIClient.ListAlertDefinitions(input) + if err != nil { + return err + } + + if ok, err := c.WriteJSON(out, definitions); ok { + // No pagination prompt w/ JSON output. + return err + } + + definitionsPtr := make([]*fastly.AlertDefinition, len(definitions.Data)) + for i := range definitions.Data { + definitionsPtr[i] = &definitions.Data[i] + } + + if c.Globals.Verbose() { + printVerbose(out, definitionsPtr) + } else { + printSummary(out, definitionsPtr) + } + + if definitions != nil && definitions.Meta.NextCursor != "" { + // Check if 'out' is interactive before prompting. + if !c.Globals.Flags.NonInteractive && !c.Globals.Flags.AutoYes && text.IsTTY(out) { + printNext, err := text.AskYesNo(out, "Print next page [y/N]: ", in) + if err != nil { + return err + } + if printNext { + input.Cursor = &definitions.Meta.NextCursor + continue + } + } + } + + return nil + } +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *ListCommand) constructInput() (*fastly.ListAlertDefinitionsInput, error) { + input := fastly.ListAlertDefinitionsInput{} + if c.cursor.WasSet { + input.Cursor = &c.cursor.Value + } + if c.limit.WasSet { + input.Limit = &c.limit.Value + } + if c.definitionName.WasSet { + input.Name = &c.definitionName.Value + } + if c.serviceID.WasSet { + input.ServiceID = &c.serviceID.Value + } + var sign string + if c.order.WasSet { + switch c.order.Value { + case "asc": + case "desc": + sign = "-" + default: + err := errors.New("'order' flag must be one of the following [asc, desc]") + c.Globals.ErrLog.Add(err) + return nil, err + } + } + + if c.sort.WasSet { + str := sign + c.sort.Value + input.Sort = &str + } + + return &input, nil +} diff --git a/pkg/commands/alerts/list_history.go b/pkg/commands/alerts/list_history.go new file mode 100644 index 000000000..673aa641e --- /dev/null +++ b/pkg/commands/alerts/list_history.go @@ -0,0 +1,154 @@ +package alerts + +import ( + "errors" + "io" + + "github.com/fastly/cli/pkg/text" + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" +) + +// NewListHistoryCommand returns a usable command registered under the parent. +func NewListHistoryCommand(parent argparser.Registerer, g *global.Data) *ListHistoryCommand { + c := ListHistoryCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("history", "List history") + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + c.CmdClause.Flag("after", "After filter history record that either started or ended after a specific date").Action(c.after.Set).StringVar(&c.after.Value) + c.CmdClause.Flag("before", "Before filter history record that either started or ended before a specific date").Action(c.before.Set).StringVar(&c.before.Value) + c.CmdClause.Flag("cursor", "Pagination cursor (Use 'next_cursor' value from list output)").Action(c.cursor.Set).StringVar(&c.cursor.Value) + c.CmdClause.Flag("definition-id", "Unique identifier of the definition").Action(c.definitionID.Set).StringVar(&c.definitionID.Value) + c.CmdClause.Flag("limit", "Maximum number of items to list").Action(c.limit.Set).IntVar(&c.limit.Value) + c.CmdClause.Flag("order", "Sort by one of the following [asc, desc]").Action(c.order.Set).StringVar(&c.order.Value) + c.CmdClause.Flag("sort", "Sort by one of the following [start]").Action(c.sort.Set).StringVar(&c.sort.Value) + c.CmdClause.Flag(argparser.FlagServiceIDName, "ServiceID of the definition").Action(c.serviceID.Set).StringVar(&c.serviceID.Value) // --service-id + c.CmdClause.Flag("status", "Status of the history record [active, resolved]").Action(c.status.Set).StringVar(&c.status.Value) + + return &c +} + +// ListHistoryCommand calls the Fastly API to list appropriate resources. +type ListHistoryCommand struct { + argparser.Base + argparser.JSONOutput + + cursor argparser.OptionalString + limit argparser.OptionalInt + sort argparser.OptionalString + order argparser.OptionalString + + status argparser.OptionalString + before argparser.OptionalString + after argparser.OptionalString + definitionID argparser.OptionalString + serviceID argparser.OptionalString +} + +// Exec invokes the application logic for the command. +func (c *ListHistoryCommand) Exec(in io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + input, err := c.constructInput() + if err != nil { + return err + } + + for { + history, err := c.Globals.APIClient.ListAlertHistory(input) + if err != nil { + return err + } + + if ok, err := c.WriteJSON(out, history); ok { + return err + } + + historyPtr := make([]*fastly.AlertHistory, len(history.Data)) + for i := range history.Data { + historyPtr[i] = &history.Data[i] + } + + if c.Globals.Verbose() { + printHistoryVerbose(out, historyPtr) + } else { + printHistorySummary(out, historyPtr) + } + + if history != nil && history.Meta.NextCursor != "" { + // Check if 'out' is interactive before prompting. + if !c.Globals.Flags.NonInteractive && !c.Globals.Flags.AutoYes && text.IsTTY(out) { + printNext, err := text.AskYesNo(out, "Print next page [y/N]: ", in) + if err != nil { + return err + } + if printNext { + input.Cursor = &history.Meta.NextCursor + continue + } + } + } + + return nil + } +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *ListHistoryCommand) constructInput() (*fastly.ListAlertHistoryInput, error) { + input := fastly.ListAlertHistoryInput{} + if c.cursor.WasSet { + input.Cursor = &c.cursor.Value + } + if c.limit.WasSet { + input.Limit = &c.limit.Value + } + var sign string + if c.order.WasSet { + switch c.order.Value { + case "asc": + case "desc": + sign = "-" + default: + err := errors.New("'order' flag must be one of the following [asc, desc]") + c.Globals.ErrLog.Add(err) + return nil, err + } + } + + if c.sort.WasSet { + str := sign + c.sort.Value + input.Sort = &str + } + + if c.serviceID.WasSet { + input.ServiceID = &c.serviceID.Value + } + + if c.definitionID.WasSet { + input.DefinitionID = &c.definitionID.Value + } + + if c.status.WasSet { + input.Status = &c.status.Value + } + + if c.before.WasSet { + input.Before = &c.before.Value + } + + if c.after.WasSet { + input.After = &c.after.Value + } + + return &input, nil +} diff --git a/pkg/commands/alerts/root.go b/pkg/commands/alerts/root.go new file mode 100644 index 000000000..eac49f82f --- /dev/null +++ b/pkg/commands/alerts/root.go @@ -0,0 +1,31 @@ +package alerts + +import ( + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" +) + +// RootCommand is the parent command for all subcommands in this package. +// It should be installed under the primary root command. +type RootCommand struct { + argparser.Base + // no flags +} + +// CommandName is the string to be used to invoke this command. +const CommandName = "alerts" + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent argparser.Registerer, g *global.Data) *RootCommand { + var c RootCommand + c.Globals = g + c.CmdClause = parent.Command(CommandName, "Manipulate Fastly Alerts") + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { + panic("unreachable") +} diff --git a/pkg/commands/alerts/update.go b/pkg/commands/alerts/update.go new file mode 100644 index 000000000..76f670a4e --- /dev/null +++ b/pkg/commands/alerts/update.go @@ -0,0 +1,120 @@ +package alerts + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" +) + +// NewUpdateCommand returns a usable command registered under the parent. +func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { + c := UpdateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + + c.CmdClause = parent.Command("update", "Update Alerts") + + // Required. + c.CmdClause.Flag("description", "Additional text that is included in the alert notification.").Required().StringVar(&c.description) + c.CmdClause.Flag("id", "A unique identifier for a definition.").Required().StringVar(&c.definitionID) + c.CmdClause.Flag("metric", "Metric name to alert on for a specific source.").Required().StringVar(&c.metric) + c.CmdClause.Flag("name", "Name of the alert definition.").Required().StringVar(&c.name) + c.CmdClause.Flag("period", "Period of time to evaluate whether the conditions have been met. The data is polled every minute.").Required().HintOptions(evaluationPeriod...).EnumVar(&c.period, evaluationPeriod...) + c.CmdClause.Flag("source", "Source where the metric comes from.").Required().StringVar(&c.source) + c.CmdClause.Flag("threshold", "Threshold used to alert.").Required().Float64Var(&c.threshold) + c.CmdClause.Flag("type", "Type of strategy to use to evaluate.").Required().HintOptions(evaluationType...).EnumVar(&c.eType, evaluationType...) + + // Optional. + c.CmdClause.Flag("dimensions", "Dimensions filters depending on the source type.").Action(c.dimensions.Set).StringsVar(&c.dimensions.Value) + c.CmdClause.Flag("ignoreBelow", "IgnoreBelow is the threshold for the denominator value used in evaluations that calculate a rate or ratio. Usually used to filter out noise.").Action(c.ignoreBelow.Set).Float64Var(&c.ignoreBelow.Value) + c.CmdClause.Flag("integrations", "Integrations are a list of integrations used to notify when alert fires.").Action(c.integrations.Set).StringsVar(&c.integrations.Value) + c.RegisterFlagBool(c.JSONFlag()) // --json + + return &c +} + +// UpdateCommand calls the Fastly API to list appropriate resources. +type UpdateCommand struct { + argparser.Base + argparser.JSONOutput + + definitionID string + description string + eType string + metric string + name string + period string + source string + threshold float64 + + dimensions argparser.OptionalStringSlice + ignoreBelow argparser.OptionalFloat64 + integrations argparser.OptionalStringSlice +} + +// Exec invokes the application logic for the command. +func (c *UpdateCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + input := c.constructInput() + definition, err := c.Globals.APIClient.UpdateAlertDefinition(input) + if err != nil { + return err + } + + if ok, err := c.WriteJSON(out, definition); ok { + return err + } + + definitions := []*fastly.AlertDefinition{definition} + if c.Globals.Verbose() { + printVerbose(out, definitions) + } else { + printSummary(out, definitions) + } + return nil +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *UpdateCommand) constructInput() *fastly.UpdateAlertDefinitionInput { + input := fastly.UpdateAlertDefinitionInput{ + ID: &c.definitionID, + Description: &c.description, + EvaluationStrategy: map[string]any{ + "type": c.eType, + "period": c.period, + "threshold": c.threshold, + }, + Metric: &c.metric, + Name: &c.name, + } + + if c.ignoreBelow.WasSet { + input.EvaluationStrategy["ignore_below"] = c.ignoreBelow.Value + } + + dimensions := map[string][]string{} + if c.source == "origins" || c.source == "domains" { + var filter []string + if c.dimensions.WasSet { + filter = c.dimensions.Value + } + dimensions[c.source] = filter + } + input.Dimensions = dimensions + + input.IntegrationIDs = []string{} + if c.integrations.WasSet { + input.IntegrationIDs = c.integrations.Value + } + + return &input +} diff --git a/pkg/commands/authtoken/authtoken_test.go b/pkg/commands/authtoken/authtoken_test.go new file mode 100644 index 000000000..e4f3ccb77 --- /dev/null +++ b/pkg/commands/authtoken/authtoken_test.go @@ -0,0 +1,332 @@ +package authtoken_test + +import ( + "fmt" + "testing" + + "github.com/fastly/go-fastly/v10/fastly" + + root "github.com/fastly/cli/pkg/commands/authtoken" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" +) + +func TestAuthTokenCreate(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --password flag", + WantError: "error parsing arguments: required flag --password not provided", + }, + { + Name: "validate CreateToken API error", + API: mock.API{ + CreateTokenFn: func(_ *fastly.CreateTokenInput) (*fastly.Token, error) { + return nil, testutil.Err + }, + }, + Args: "--password secure --token 123", + WantError: testutil.Err.Error(), + }, + { + Name: "validate CreateToken API success with no flags", + API: mock.API{ + CreateTokenFn: func(_ *fastly.CreateTokenInput) (*fastly.Token, error) { + return &fastly.Token{ + ExpiresAt: &testutil.Date, + TokenID: fastly.ToPointer("123"), + Name: fastly.ToPointer("Example"), + Scope: fastly.ToPointer(fastly.TokenScope("foobar")), + AccessToken: fastly.ToPointer("123abc"), + }, nil + }, + }, + Args: "--password secure --token 123", + WantOutput: "Created token '123abc' (name: Example, id: 123, scope: foobar, expires: 2021-06-15 23:00:00 +0000 UTC)", + }, + { + Name: "validate CreateToken API success with all flags", + API: mock.API{ + CreateTokenFn: func(i *fastly.CreateTokenInput) (*fastly.Token, error) { + return &fastly.Token{ + ExpiresAt: i.ExpiresAt, + TokenID: fastly.ToPointer("123"), + Name: i.Name, + Scope: i.Scope, + AccessToken: fastly.ToPointer("123abc"), + }, nil + }, + }, + Args: "--expires 2021-09-15T23:00:00Z --name Testing --password secure --scope purge_all --scope global:read --services a,b,c --token 123", + WantOutput: "Created token '123abc' (name: Testing, id: 123, scope: purge_all global:read, expires: 2021-09-15 23:00:00 +0000 UTC)", + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, "create"}, scenarios) +} + +func TestAuthTokenDelete(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing optional flags", + Args: "--token 123", + WantError: "error parsing arguments: must provide either the --current, --file or --id flag", + }, + { + Name: "validate DeleteTokenSelf API error with --current", + API: mock.API{ + DeleteTokenSelfFn: func() error { + return testutil.Err + }, + }, + Args: "--current --token 123", + WantError: testutil.Err.Error(), + }, + { + Name: "validate BatchDeleteTokens API error with --file", + API: mock.API{ + BatchDeleteTokensFn: func(_ *fastly.BatchDeleteTokensInput) error { + return testutil.Err + }, + }, + Args: "--file ./testdata/tokens --token 123", + WantError: testutil.Err.Error(), + }, + { + Name: "validate DeleteToken API error with --id", + API: mock.API{ + DeleteTokenFn: func(_ *fastly.DeleteTokenInput) error { + return testutil.Err + }, + }, + Args: "--id 123 --token 123", + WantError: testutil.Err.Error(), + }, + { + Name: "validate DeleteTokenSelf API success with --current", + API: mock.API{ + DeleteTokenSelfFn: func() error { + return nil + }, + }, + Args: "--current --token 123", + WantOutput: "Deleted current token", + }, + { + Name: "validate BatchDeleteTokens API success with --file", + API: mock.API{ + BatchDeleteTokensFn: func(_ *fastly.BatchDeleteTokensInput) error { + return nil + }, + }, + Args: "--file ./testdata/tokens --token 123", + WantOutput: "Deleted tokens", + }, + { + Name: "validate BatchDeleteTokens API success with --file and --verbose", + API: mock.API{ + BatchDeleteTokensFn: func(_ *fastly.BatchDeleteTokensInput) error { + return nil + }, + }, + Args: "--file ./testdata/tokens --token 123 --verbose", + WantOutput: fileTokensOutput(), + }, + { + Name: "validate DeleteToken API success with --id", + API: mock.API{ + DeleteTokenFn: func(_ *fastly.DeleteTokenInput) error { + return nil + }, + }, + Args: "--id 123 --token 123", + WantOutput: "Deleted token '123'", + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, "delete"}, scenarios) +} + +func TestAuthTokenDescribe(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate GetTokenSelf API error", + API: mock.API{ + GetTokenSelfFn: func() (*fastly.Token, error) { + return nil, testutil.Err + }, + }, + Args: "--token 123", + WantError: testutil.Err.Error(), + }, + { + Name: "validate GetTokenSelf API success", + API: mock.API{ + GetTokenSelfFn: getToken, + }, + Args: "--token 123", + WantOutput: describeTokenOutput(), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, "describe"}, scenarios) +} + +func TestAuthTokenList(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate ListTokens API error", + API: mock.API{ + ListTokensFn: func(_ *fastly.ListTokensInput) ([]*fastly.Token, error) { + return nil, testutil.Err + }, + }, + WantError: testutil.Err.Error(), + }, + { + Name: "validate ListCustomerTokens API error", + API: mock.API{ + ListCustomerTokensFn: func(_ *fastly.ListCustomerTokensInput) ([]*fastly.Token, error) { + return nil, testutil.Err + }, + }, + Args: "--customer-id 123", + WantError: testutil.Err.Error(), + }, + { + Name: "validate ListTokens API success", + API: mock.API{ + ListTokensFn: listTokens, + }, + WantOutput: listTokenOutputSummary(false), + }, + { + Name: "validate ListCustomerTokens API success", + API: mock.API{ + ListCustomerTokensFn: listCustomerTokens, + }, + Args: "--customer-id 123", + WantOutput: listTokenOutputSummary(false), + }, + { + Name: "validate ListCustomerTokens API success with env var", + API: mock.API{ + ListCustomerTokensFn: listCustomerTokens, + }, + WantOutput: listTokenOutputSummary(true), + EnvVars: map[string]string{"FASTLY_CUSTOMER_ID": "123"}, + }, + { + Name: "validate --verbose flag", + API: mock.API{ + ListTokensFn: listTokens, + }, + Args: "--verbose", + WantOutput: listTokenOutputVerbose(), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, "list"}, scenarios) +} + +func getToken() (*fastly.Token, error) { + t := testutil.Date + + return &fastly.Token{ + TokenID: fastly.ToPointer("123"), + Name: fastly.ToPointer("Foo"), + UserID: fastly.ToPointer("456"), + Services: []string{"a", "b"}, + Scope: fastly.ToPointer(fastly.TokenScope(fmt.Sprintf("%s %s", fastly.PurgeAllScope, fastly.GlobalReadScope))), + IP: fastly.ToPointer("127.0.0.1"), + CreatedAt: &t, + ExpiresAt: &t, + LastUsedAt: &t, + }, nil +} + +func listTokens(_ *fastly.ListTokensInput) ([]*fastly.Token, error) { + t := testutil.Date + token, _ := getToken() + vs := []*fastly.Token{ + token, + { + TokenID: fastly.ToPointer("456"), + Name: fastly.ToPointer("Bar"), + UserID: fastly.ToPointer("789"), + Services: []string{"a", "b"}, + Scope: fastly.ToPointer(fastly.GlobalScope), + IP: fastly.ToPointer("127.0.0.2"), + CreatedAt: &t, + ExpiresAt: &t, + LastUsedAt: &t, + }, + } + return vs, nil +} + +func listCustomerTokens(_ *fastly.ListCustomerTokensInput) ([]*fastly.Token, error) { + return listTokens(nil) +} + +func fileTokensOutput() string { + return `Deleted tokens + +TOKEN ID +abc +def +xyz` +} + +func describeTokenOutput() string { + return ` +ID: 123 +Name: Foo +User ID: 456 +Services: a, b +Scope: purge_all global:read +IP: 127.0.0.1 + +Created at: 2021-06-15 23:00:00 +0000 UTC +Last used at: 2021-06-15 23:00:00 +0000 UTC +Expires at: 2021-06-15 23:00:00 +0000 UTC` +} + +func listTokenOutputVerbose() string { + return `Fastly API endpoint: https://api.fastly.com +Fastly API token provided via config file (profile: user) + + +ID: 123 +Name: Foo +User ID: 456 +Services: a, b +Scope: purge_all global:read +IP: 127.0.0.1 + +Created at: 2021-06-15 23:00:00 +0000 UTC +Last used at: 2021-06-15 23:00:00 +0000 UTC +Expires at: 2021-06-15 23:00:00 +0000 UTC + +ID: 456 +Name: Bar +User ID: 789 +Services: a, b +Scope: global +IP: 127.0.0.2 + +Created at: 2021-06-15 23:00:00 +0000 UTC +Last used at: 2021-06-15 23:00:00 +0000 UTC +Expires at: 2021-06-15 23:00:00 +0000 UTC + +` +} + +func listTokenOutputSummary(env bool) string { + var msg string + if env { + msg = "INFO: Listing customer tokens for the FASTLY_CUSTOMER_ID environment variable\n\n" + } + return fmt.Sprintf(`%sNAME TOKEN ID USER ID SCOPE SERVICES +Foo 123 456 purge_all global:read a, b +Bar 456 789 global a, b`, msg) +} diff --git a/pkg/commands/authtoken/create.go b/pkg/commands/authtoken/create.go new file mode 100644 index 000000000..48bd19217 --- /dev/null +++ b/pkg/commands/authtoken/create.go @@ -0,0 +1,101 @@ +package authtoken + +import ( + "io" + "strings" + "time" + + "github.com/fastly/go-fastly/v10/fastly" + "github.com/fastly/kingpin" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// Scopes is a list of purging scope options. +// https://www.fastly.com/documentation/reference/api/auth-tokens#scopes +var Scopes = []string{"global", "purge_select", "purge_all", "global:read"} + +// NewCreateCommand returns a usable command registered under the parent. +func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { + c := CreateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("create", "Create an API token").Alias("add") + + // Required. + // + // NOTE: The go-fastly client internally calls `/sudo` before `/tokens` and + // the sudo endpoint requires a password to be provided alongside an API + // token. The password must be for the user account that created the token + // being passed as authentication to the API endpoint. + c.CmdClause.Flag("password", "User password corresponding with --token or $FASTLY_API_TOKEN").Required().StringVar(&c.password) + + // Optional. + // + // NOTE: The API describes 'scope' as being space-delimited but we've opted + // for comma-separated as it means users don't have to worry about how best + // to handle issues with passing a flag value with whitespace. When + // constructing the input for the API call we convert from a comma-separated + // value to a space-delimited value. + c.CmdClause.Flag("expires", "Time-stamp (UTC) of when the token will expire").HintOptions("2016-07-28T19:24:50+00:00").TimeVar(time.RFC3339, &c.expires) + c.CmdClause.Flag("name", "Name of the token").StringVar(&c.name) + c.CmdClause.Flag("scope", "Authorization scope (repeat flag per scope)").HintOptions(Scopes...).EnumsVar(&c.scope, Scopes...) + c.CmdClause.Flag("services", "A comma-separated list of alphanumeric strings identifying services (default: access to all services)").StringsVar(&c.services, kingpin.Separator(",")) + return &c +} + +// CreateCommand calls the Fastly API to create an appropriate resource. +type CreateCommand struct { + argparser.Base + + expires time.Time + name string + password string + scope []string + services []string +} + +// Exec invokes the application logic for the command. +func (c *CreateCommand) Exec(_ io.Reader, out io.Writer) error { + input := c.constructInput() + + r, err := c.Globals.APIClient.CreateToken(input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + expires := "never" + if r.ExpiresAt != nil { + expires = r.ExpiresAt.String() + } + + text.Success(out, "Created token '%s' (name: %s, id: %s, scope: %s, expires: %s)", fastly.ToValue(r.AccessToken), fastly.ToValue(r.Name), fastly.ToValue(r.TokenID), fastly.ToValue(r.Scope), expires) + return nil +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *CreateCommand) constructInput() *fastly.CreateTokenInput { + var input fastly.CreateTokenInput + + input.Password = fastly.ToPointer(c.password) + + if !c.expires.IsZero() { + input.ExpiresAt = &c.expires + } + if c.name != "" { + input.Name = fastly.ToPointer(c.name) + } + if len(c.scope) > 0 { + input.Scope = fastly.ToPointer(fastly.TokenScope(strings.Join(c.scope, " "))) + } + if len(c.services) > 0 { + input.Services = c.services + } + + return &input +} diff --git a/pkg/commands/authtoken/delete.go b/pkg/commands/authtoken/delete.go new file mode 100644 index 000000000..45c642890 --- /dev/null +++ b/pkg/commands/authtoken/delete.go @@ -0,0 +1,143 @@ +package authtoken + +import ( + "bufio" + "fmt" + "io" + "os" + "path/filepath" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// NewDeleteCommand returns a usable command registered under the parent. +func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { + c := DeleteCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("delete", "Revoke an API token").Alias("remove") + + c.CmdClause.Flag("current", "Revoke the token used to authenticate the request").BoolVar(&c.current) + c.CmdClause.Flag("file", "Revoke tokens in bulk from a newline delimited list of tokens").StringVar(&c.file) + c.CmdClause.Flag("id", "Alphanumeric string identifying a token").StringVar(&c.id) + return &c +} + +// DeleteCommand calls the Fastly API to delete an appropriate resource. +type DeleteCommand struct { + argparser.Base + + current bool + file string + id string +} + +// Exec invokes the application logic for the command. +func (c *DeleteCommand) Exec(_ io.Reader, out io.Writer) error { + if !c.current && c.file == "" && c.id == "" { + return fmt.Errorf("error parsing arguments: must provide either the --current, --file or --id flag") + } + + if c.current { + err := c.Globals.APIClient.DeleteTokenSelf() + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + text.Success(out, "Deleted current token") + return nil + } + + if c.file != "" { + input, err := c.constructInputBatch() + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + err = c.Globals.APIClient.BatchDeleteTokens(input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + text.Success(out, "Deleted tokens") + if c.Globals.Verbose() { + text.Break(out) + c.printTokens(out, input.Tokens) + } + return nil + } + + if c.id != "" { + input := c.constructInput() + + err := c.Globals.APIClient.DeleteToken(input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + text.Success(out, "Deleted token '%s'", c.id) + return nil + } + + return nil +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *DeleteCommand) constructInput() *fastly.DeleteTokenInput { + var input fastly.DeleteTokenInput + + input.TokenID = c.id + + return &input +} + +// constructInputBatch transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *DeleteCommand) constructInputBatch() (*fastly.BatchDeleteTokensInput, error) { + var ( + err error + file io.Reader + input fastly.BatchDeleteTokensInput + path string + tokens []*fastly.BatchToken + ) + + if path, err = filepath.Abs(c.file); err == nil { + if _, err = os.Stat(path); err == nil { + if file, err = os.Open(path); err == nil /* #nosec */ { + scanner := bufio.NewScanner(file) + for scanner.Scan() { + tokens = append(tokens, &fastly.BatchToken{ID: scanner.Text()}) + } + err = scanner.Err() + } + } + } + + input.Tokens = tokens + + if err != nil { + return nil, err + } + + return &input, nil +} + +// printTokens displays the tokens provided by a user. +func (c *DeleteCommand) printTokens(out io.Writer, rs []*fastly.BatchToken) { + t := text.NewTable(out) + t.AddHeader("TOKEN ID") + for _, r := range rs { + t.AddLine(r.ID) + } + t.Print() +} diff --git a/pkg/commands/authtoken/describe.go b/pkg/commands/authtoken/describe.go new file mode 100644 index 000000000..08c831df9 --- /dev/null +++ b/pkg/commands/authtoken/describe.go @@ -0,0 +1,72 @@ +package authtoken + +import ( + "fmt" + "io" + "strings" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" +) + +// NewDescribeCommand returns a usable command registered under the parent. +func NewDescribeCommand(parent argparser.Registerer, g *global.Data) *DescribeCommand { + c := DescribeCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("describe", "Get the current API token").Alias("get") + + c.RegisterFlagBool(c.JSONFlag()) // --json + return &c +} + +// DescribeCommand calls the Fastly API to describe an appropriate resource. +type DescribeCommand struct { + argparser.Base + argparser.JSONOutput +} + +// Exec invokes the application logic for the command. +func (c *DescribeCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + o, err := c.Globals.APIClient.GetTokenSelf() + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + return c.print(out, o) +} + +// print displays the information returned from the API. +func (c *DescribeCommand) print(out io.Writer, t *fastly.Token) error { + fmt.Fprintf(out, "\nID: %s\n", fastly.ToValue(t.TokenID)) + fmt.Fprintf(out, "Name: %s\n", fastly.ToValue(t.Name)) + fmt.Fprintf(out, "User ID: %s\n", fastly.ToValue(t.UserID)) + fmt.Fprintf(out, "Services: %s\n", strings.Join(t.Services, ", ")) + fmt.Fprintf(out, "Scope: %s\n", fastly.ToValue(t.Scope)) + fmt.Fprintf(out, "IP: %s\n\n", fastly.ToValue(t.IP)) + + if t.CreatedAt != nil { + fmt.Fprintf(out, "Created at: %s\n", t.CreatedAt) + } + if t.LastUsedAt != nil { + fmt.Fprintf(out, "Last used at: %s\n", t.LastUsedAt) + } + if t.ExpiresAt != nil { + fmt.Fprintf(out, "Expires at: %s\n", t.ExpiresAt) + } + return nil +} diff --git a/pkg/commands/authtoken/doc.go b/pkg/commands/authtoken/doc.go new file mode 100644 index 000000000..913c3780e --- /dev/null +++ b/pkg/commands/authtoken/doc.go @@ -0,0 +1,3 @@ +// Package authtoken contains commands to manage API tokens for Fastly service +// users. +package authtoken diff --git a/pkg/commands/authtoken/list.go b/pkg/commands/authtoken/list.go new file mode 100644 index 000000000..3be06e4e0 --- /dev/null +++ b/pkg/commands/authtoken/list.go @@ -0,0 +1,136 @@ +package authtoken + +import ( + "fmt" + "io" + "strings" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// NewListCommand returns a usable command registered under the parent. +func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { + c := ListCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("list", "List API tokens") + + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagCustomerIDName, + Description: argparser.FlagCustomerIDDesc, + Dst: &c.customerID.Value, + Action: c.customerID.Set, + }) + c.RegisterFlagBool(c.JSONFlag()) // --json + return &c +} + +// ListCommand calls the Fastly API to list appropriate resources. +type ListCommand struct { + argparser.Base + argparser.JSONOutput + + customerID argparser.OptionalCustomerID +} + +// Exec invokes the application logic for the command. +func (c *ListCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + var ( + err error + o []*fastly.Token + ) + + if err = c.customerID.Parse(); err == nil { + if !c.customerID.WasSet && !c.Globals.Flags.Quiet { + text.Info(out, "Listing customer tokens for the FASTLY_CUSTOMER_ID environment variable\n\n") + } + input := c.constructInput() + o, err = c.Globals.APIClient.ListCustomerTokens(input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + } else { + o, err = c.Globals.APIClient.ListTokens(&fastly.ListTokensInput{}) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + if c.Globals.Verbose() { + c.printVerbose(out, o) + } else { + err = c.printSummary(out, o) + if err != nil { + return err + } + } + return nil +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *ListCommand) constructInput() *fastly.ListCustomerTokensInput { + var input fastly.ListCustomerTokensInput + + input.CustomerID = c.customerID.Value + + return &input +} + +// printVerbose displays the information returned from the API in a verbose +// format. +func (c *ListCommand) printVerbose(out io.Writer, rs []*fastly.Token) { + for _, r := range rs { + fmt.Fprintf(out, "\nID: %s\n", fastly.ToValue(r.TokenID)) + fmt.Fprintf(out, "Name: %s\n", fastly.ToValue(r.Name)) + fmt.Fprintf(out, "User ID: %s\n", fastly.ToValue(r.UserID)) + fmt.Fprintf(out, "Services: %s\n", strings.Join(r.Services, ", ")) + fmt.Fprintf(out, "Scope: %s\n", fastly.ToValue(r.Scope)) + fmt.Fprintf(out, "IP: %s\n\n", fastly.ToValue(r.IP)) + + if r.CreatedAt != nil { + fmt.Fprintf(out, "Created at: %s\n", r.CreatedAt) + } + if r.LastUsedAt != nil { + fmt.Fprintf(out, "Last used at: %s\n", r.LastUsedAt) + } + if r.ExpiresAt != nil { + fmt.Fprintf(out, "Expires at: %s\n", r.ExpiresAt) + } + } + fmt.Fprintf(out, "\n") +} + +// printSummary displays the information returned from the API in a summarised +// format. +func (c *ListCommand) printSummary(out io.Writer, ts []*fastly.Token) error { + tbl := text.NewTable(out) + tbl.AddHeader("NAME", "TOKEN ID", "USER ID", "SCOPE", "SERVICES") + for _, t := range ts { + tbl.AddLine( + fastly.ToValue(t.Name), + fastly.ToValue(t.TokenID), + fastly.ToValue(t.UserID), + fastly.ToValue(t.Scope), + strings.Join(t.Services, ", "), + ) + } + tbl.Print() + return nil +} diff --git a/pkg/commands/authtoken/root.go b/pkg/commands/authtoken/root.go new file mode 100644 index 000000000..cee162184 --- /dev/null +++ b/pkg/commands/authtoken/root.go @@ -0,0 +1,31 @@ +package authtoken + +import ( + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" +) + +// RootCommand is the parent command for all subcommands in this package. +// It should be installed under the primary root command. +type RootCommand struct { + argparser.Base + // no flags +} + +// CommandName is the string to be used to invoke this command. +const CommandName = "auth-token" + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent argparser.Registerer, g *global.Data) *RootCommand { + var c RootCommand + c.Globals = g + c.CmdClause = parent.Command(CommandName, "Manage API tokens for Fastly service users") + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { + panic("unreachable") +} diff --git a/pkg/commands/authtoken/testdata/tokens b/pkg/commands/authtoken/testdata/tokens new file mode 100644 index 000000000..1b7cdf175 --- /dev/null +++ b/pkg/commands/authtoken/testdata/tokens @@ -0,0 +1,3 @@ +abc +def +xyz diff --git a/pkg/commands/backend/backend_test.go b/pkg/commands/backend/backend_test.go new file mode 100644 index 000000000..f78d16b38 --- /dev/null +++ b/pkg/commands/backend/backend_test.go @@ -0,0 +1,650 @@ +package backend_test + +import ( + "errors" + "io" + "net/http" + "strings" + "testing" + + "github.com/fastly/go-fastly/v10/fastly" + + root "github.com/fastly/cli/pkg/commands/backend" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" +) + +func TestBackendCreate(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Args: "--version 1", + WantError: "error reading service: no service ID found", + }, + // The following test specifies a service version that's 'active', and + // subsequently we expect it to not be cloned as we don't provide the + // --autoclone flag and trying to add a backend to an activated service + // should cause an error. + { + Args: "--service-id 123 --version 1 --address example.com --name www.test.com", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + }, + WantError: "service version 1 is active", + }, + // The following test specifies a service version that's 'locked', and + // subsequently we expect it to not be cloned as we don't provide the + // --autoclone flag and trying to add a backend to a locked service + // should cause an error. + { + Args: "--service-id 123 --version 2 --address example.com --name www.test.com", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + }, + WantError: "service version 2 is locked", + }, + // The following test is the same as the 'active' test above but it appends --autoclone + // so we can be sure the backend creation error still occurs. + { + Args: "--service-id 123 --version 1 --address example.com --name www.test.com --autoclone", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + CreateBackendFn: createBackendError, + }, + WantError: errTest.Error(), + }, + // The following test is the same as the 'locked' test above but it appends --autoclone + // so we can be sure the backend creation error still occurs. + { + Args: "--service-id 123 --version 1 --address example.com --name www.test.com --autoclone", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + CreateBackendFn: createBackendError, + }, + WantError: errTest.Error(), + }, + // The following test is the same as above but with an IP address for the + // --address flag instead of a hostname. + { + Args: "--service-id 123 --version 1 --address 127.0.0.1 --name www.test.com --autoclone", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + CreateBackendFn: createBackendError, + }, + WantError: errTest.Error(), + }, + // The following test is the same as above but mocks a successful backend + // creation so we can validate the correct service version was utilised. + // + // NOTE: Added --port flag to validate that a nil pointer dereference is + // not triggered at runtime when parsing the arguments. + { + Args: "--service-id 123 --version 1 --address 127.0.0.1 --name www.test.com --autoclone --port 8080", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + CreateBackendFn: createBackendWithPort(8080), + }, + WantOutput: "Created backend www.test.com (service 123 version 4)", + }, + // We test that setting an invalid host override does not result in an error + { + Args: "--service-id 123 --version 1 --address 127.0.0.1 --override-host invalid-host-override --name www.test.com --autoclone --port 8080", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + CreateBackendFn: createBackendWithPort(8080), + }, + WantOutput: "Created backend www.test.com (service 123 version 4)", + }, + // The following test validates that --service-name can replace --service-id + { + Args: "--service-name test-service --version 1 --address 127.0.0.1 --name www.test.com --autoclone", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + GetServicesFn: func(_ *fastly.GetServicesInput) *fastly.ListPaginator[fastly.Service] { + return fastly.NewPaginator[fastly.Service](&mock.HTTPClient{ + Errors: []error{nil}, + Responses: []*http.Response{ + { + Body: io.NopCloser(strings.NewReader(`[{"id": "123", "name": "test-service"}]`)), + }, + }, + }, fastly.ListOpts{}, "/example") + }, + CloneVersionFn: testutil.CloneVersionResult(4), + CreateBackendFn: createBackendOK, + }, + WantOutput: "Created backend www.test.com (service 123 version 4)", + }, + // The following test is the same as above but appends both --use-ssl and + // --verbose so we may validate the expected output message regarding a + // missing port is displayed. + { + Args: "--service-id 123 --version 1 --address 127.0.0.1 --name www.test.com --autoclone --use-ssl --verbose", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + CreateBackendFn: createBackendWithPort(443), + }, + WantOutput: "Use-ssl was set but no port was specified, using default port 443", + }, + // The following test is the same as above but appends --port, --use-ssl and + // --verbose so we may validate a successful backend creation. + // + { + Args: "--service-id 123 --version 1 --address 127.0.0.1 --name www.test.com --autoclone --port 8443 --use-ssl --verbose", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + CreateBackendFn: createBackendWithPort(8443), + }, + WantOutput: "Created backend www.test.com (service 123 version 4)", + }, + // The following test specifies a service version that's 'inactive' and not 'locked', + // and subsequently we expect it to be the same editable version. + { + Args: "--service-id 123 --version 3 --address 127.0.0.1 --name www.test.com", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + CreateBackendFn: createBackendOK, + }, + WantOutput: "Created backend www.test.com (service 123 version 3)", + }, + // The following tests verify parsing of the --tcp-ka-enable flag. + { + Args: "--service-id 123 --version 3 --address 127.0.0.1 --name www.test.com --tcp-ka-enabled=true", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + CreateBackendFn: createBackendOK, + }, + WantOutput: "Created backend www.test.com (service 123 version 3)", + }, + { + Args: "--service-id 123 --version 3 --address 127.0.0.1 --name www.test.com --tcp-ka-enabled=false", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + CreateBackendFn: createBackendOK, + }, + WantOutput: "Created backend www.test.com (service 123 version 3)", + }, + { + Args: "--service-id 123 --version 3 --address 127.0.0.1 --name www.test.com --tcp-ka-enabled=invalid", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + CreateBackendFn: createBackendOK, + }, + WantError: "'tcp-ka-enable' flag must be one of the following [true, false]", + }, + } + testutil.RunCLIScenarios(t, []string{root.CommandName, "create"}, scenarios) +} + +func TestBackendList(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Args: "--service-id 123 --version 1 --json", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListBackendsFn: listBackendsOK, + }, + WantOutput: listBackendsJSONOutput, + }, + { + Args: "--service-id 123 --version 1 --json --verbose", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListBackendsFn: listBackendsOK, + }, + WantError: fsterr.ErrInvalidVerboseJSONCombo.Error(), + }, + { + Args: "--service-id 123 --version 1", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListBackendsFn: listBackendsOK, + }, + WantOutput: listBackendsShortOutput, + }, + { + Args: "--service-id 123 --version 1 --verbose", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListBackendsFn: listBackendsOK, + }, + WantOutput: listBackendsVerboseOutput, + }, + { + Args: "--service-id 123 --version 1 -v", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListBackendsFn: listBackendsOK, + }, + WantOutput: listBackendsVerboseOutput, + }, + { + Args: "--verbose --service-id 123 --version 1", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListBackendsFn: listBackendsOK, + }, + WantOutput: listBackendsVerboseOutput, + }, + { + Args: "-v --service-id 123 --version 1", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListBackendsFn: listBackendsOK, + }, + WantOutput: listBackendsVerboseOutput, + }, + { + Args: "--service-id 123 --version 1", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListBackendsFn: listBackendsError, + }, + WantError: errTest.Error(), + }, + } + testutil.RunCLIScenarios(t, []string{root.CommandName, "list"}, scenarios) +} + +func TestBackendDescribe(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Args: "--service-id 123 --version 1", + WantError: "error parsing arguments: required flag --name not provided", + }, + { + Args: "--service-id 123 --version 1 --name www.test.com", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + GetBackendFn: getBackendError, + }, + WantError: errTest.Error(), + }, + { + Args: "--service-id 123 --version 1 --name www.test.com", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + GetBackendFn: getBackendOK, + }, + WantOutput: describeBackendOutput, + }, + } + testutil.RunCLIScenarios(t, []string{root.CommandName, "describe"}, scenarios) +} + +func TestBackendUpdate(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Args: "--service-id 123 --version 2 --new-name www.test.com --comment ", + WantError: "error parsing arguments: required flag --name not provided", + }, + { + Args: "--service-id 123 --version 1 --name www.test.com --new-name www.example.com --autoclone", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + GetBackendFn: getBackendOK, + UpdateBackendFn: updateBackendError, + }, + WantError: errTest.Error(), + }, + { + Args: "--service-id 123 --version 1 --name www.test.com --new-name www.example.com --comment --autoclone", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + GetBackendFn: getBackendOK, + UpdateBackendFn: updateBackendOK, + }, + WantOutput: "Updated backend www.example.com (service 123 version 4)", + }, + // The following tests verify parsing of the --tcp-ka-enable flag. + { + Args: "--service-id 123 --version 1 --name www.test.com --tcp-ka-enabled=true --autoclone", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + GetBackendFn: getBackendOK, + UpdateBackendFn: updateBackendOK, + }, + WantOutput: "Updated backend (service 123 version 4)", + }, + { + Args: "--service-id 123 --version 1 --name www.test.com --tcp-ka-enabled=false --autoclone", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + GetBackendFn: getBackendOK, + UpdateBackendFn: updateBackendOK, + }, + WantOutput: "Updated backend (service 123 version 4)", + }, + { + Args: "--service-id 123 --version 1 --name www.test.com --tcp-ka-enabled=invalid --autoclone", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + GetBackendFn: getBackendOK, + UpdateBackendFn: updateBackendOK, + }, + WantError: "'tcp-ka-enable' flag must be one of the following [true, false]", + }, + } + testutil.RunCLIScenarios(t, []string{root.CommandName, "update"}, scenarios) +} + +func TestBackendDelete(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Args: "--service-id 123 --version 1", + WantError: "error parsing arguments: required flag --name not provided", + }, + { + Args: "--service-id 123 --version 1 --name www.test.com --autoclone", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + DeleteBackendFn: deleteBackendError, + }, + WantError: errTest.Error(), + }, + { + Args: "--service-id 123 --version 1 --name www.test.com --autoclone", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + DeleteBackendFn: deleteBackendOK, + }, + WantOutput: "Deleted backend www.test.com (service 123 version 4)", + }, + } + testutil.RunCLIScenarios(t, []string{root.CommandName, "delete"}, scenarios) +} + +var errTest = errors.New("fixture error") + +func createBackendOK(i *fastly.CreateBackendInput) (*fastly.Backend, error) { + if i.Name == nil { + i.Name = fastly.ToPointer("") + } + return &fastly.Backend{ + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: i.Name, + }, nil +} + +func createBackendError(_ *fastly.CreateBackendInput) (*fastly.Backend, error) { + return nil, errTest +} + +func createBackendWithPort(wantPort int) func(*fastly.CreateBackendInput) (*fastly.Backend, error) { + return func(i *fastly.CreateBackendInput) (*fastly.Backend, error) { + switch { + // if overridehost is set, should be a non "" value + case i.Port != nil && *i.Port == wantPort && ((i.OverrideHost == nil) || (i.OverrideHost != nil && *i.OverrideHost != "")): + return createBackendOK(i) + default: + return createBackendError(i) + } + } +} + +func listBackendsOK(i *fastly.ListBackendsInput) ([]*fastly.Backend, error) { + return []*fastly.Backend{ + { + Address: fastly.ToPointer("www.test.com"), + Comment: fastly.ToPointer("test"), + Name: fastly.ToPointer("test.com"), + Port: fastly.ToPointer(80), + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + }, + { + Address: fastly.ToPointer("www.example.com"), + Comment: fastly.ToPointer("example"), + Name: fastly.ToPointer("example.com"), + Port: fastly.ToPointer(443), + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + }, + }, nil +} + +func listBackendsError(_ *fastly.ListBackendsInput) ([]*fastly.Backend, error) { + return nil, errTest +} + +var listBackendsJSONOutput = strings.TrimSpace(` +[ + { + "Address": "www.test.com", + "AutoLoadbalance": null, + "BetweenBytesTimeout": null, + "Comment": "test", + "ConnectTimeout": null, + "CreatedAt": null, + "DeletedAt": null, + "ErrorThreshold": null, + "FirstByteTimeout": null, + "HealthCheck": null, + "Hostname": null, + "KeepAliveTime": null, + "MaxConn": null, + "MaxTLSVersion": null, + "MinTLSVersion": null, + "Name": "test.com", + "OverrideHost": null, + "Port": 80, + "PreferIPv6": null, + "RequestCondition": null, + "ShareKey": null, + "SSLCACert": null, + "SSLCertHostname": null, + "SSLCheckCert": null, + "SSLCiphers": null, + "SSLClientCert": null, + "SSLClientKey": null, + "SSLSNIHostname": null, + "ServiceID": "123", + "ServiceVersion": 1, + "Shield": null, + "TCPKeepAliveEnable": null, + "TCPKeepAliveIntvl": null, + "TCPKeepAliveProbes": null, + "TCPKeepAliveTime": null, + "UpdatedAt": null, + "UseSSL": null, + "Weight": null + }, + { + "Address": "www.example.com", + "AutoLoadbalance": null, + "BetweenBytesTimeout": null, + "Comment": "example", + "ConnectTimeout": null, + "CreatedAt": null, + "DeletedAt": null, + "ErrorThreshold": null, + "FirstByteTimeout": null, + "HealthCheck": null, + "Hostname": null, + "KeepAliveTime": null, + "MaxConn": null, + "MaxTLSVersion": null, + "MinTLSVersion": null, + "Name": "example.com", + "OverrideHost": null, + "Port": 443, + "PreferIPv6": null, + "RequestCondition": null, + "ShareKey": null, + "SSLCACert": null, + "SSLCertHostname": null, + "SSLCheckCert": null, + "SSLCiphers": null, + "SSLClientCert": null, + "SSLClientKey": null, + "SSLSNIHostname": null, + "ServiceID": "123", + "ServiceVersion": 1, + "Shield": null, + "TCPKeepAliveEnable": null, + "TCPKeepAliveIntvl": null, + "TCPKeepAliveProbes": null, + "TCPKeepAliveTime": null, + "UpdatedAt": null, + "UseSSL": null, + "Weight": null + } +] +`) + "\n" + +var listBackendsShortOutput = strings.TrimSpace(` +SERVICE VERSION NAME ADDRESS PORT COMMENT +123 1 test.com www.test.com 80 test +123 1 example.com www.example.com 443 example +`) + "\n" + +var listBackendsVerboseOutput = strings.Join([]string{ + "Fastly API endpoint: https://api.fastly.com", + "Fastly API token provided via config file (profile: user)", + "", + "Service ID (via --service-id): 123", + "", + "Version: 1", + " Backend 1/2", + " Name: test.com", + " Comment: test", + " Address: www.test.com", + " Port: 80", + " Override host: ", + " Connect timeout: 0", + " Max connections: 0", + " First byte timeout: 0", + " Between bytes timeout: 0", + " Auto loadbalance: false", + " Weight: 0", + " Healthcheck: ", + " Shield: ", + " Use SSL: false", + " SSL check cert: false", + " SSL CA cert: ", + " SSL client cert: ", + " SSL client key: ", + " SSL cert hostname: ", + " SSL SNI hostname: ", + " Min TLS version: ", + " Max TLS version: ", + " SSL ciphers: ", + " HTTP KeepAlive Timeout: 0", + " TCP KeepAlive Enabled: unset", + " TCP KeepAlive Interval: 0", + " TCP KeepAlive Probes: 0", + " TCP KeepAlive Timeout: 0", + " Backend 2/2", + " Name: example.com", + " Comment: example", + " Address: www.example.com", + " Port: 443", + " Override host: ", + " Connect timeout: 0", + " Max connections: 0", + " First byte timeout: 0", + " Between bytes timeout: 0", + " Auto loadbalance: false", + " Weight: 0", + " Healthcheck: ", + " Shield: ", + " Use SSL: false", + " SSL check cert: false", + " SSL CA cert: ", + " SSL client cert: ", + " SSL client key: ", + " SSL cert hostname: ", + " SSL SNI hostname: ", + " Min TLS version: ", + " Max TLS version: ", + " SSL ciphers: ", + " HTTP KeepAlive Timeout: 0", + " TCP KeepAlive Enabled: unset", + " TCP KeepAlive Interval: 0", + " TCP KeepAlive Probes: 0", + " TCP KeepAlive Timeout: 0", +}, "\n") + "\n\n" + +func getBackendOK(i *fastly.GetBackendInput) (*fastly.Backend, error) { + return &fastly.Backend{ + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("test.com"), + Address: fastly.ToPointer("www.test.com"), + Port: fastly.ToPointer(80), + Comment: fastly.ToPointer("test"), + }, nil +} + +func getBackendError(_ *fastly.GetBackendInput) (*fastly.Backend, error) { + return nil, errTest +} + +var describeBackendOutput = strings.Join([]string{ + "\nService ID: 123", + "Service Version: 1\n", + "Name: test.com", + "Comment: test", + "Address: www.test.com", + "Port: 80", + "Override host: ", + "Connect timeout: 0", + "Max connections: 0", + "First byte timeout: 0", + "Between bytes timeout: 0", + "Auto loadbalance: false", + "Weight: 0", + "Healthcheck: ", + "Shield: ", + "Use SSL: false", + "SSL check cert: false", + "SSL CA cert: ", + "SSL client cert: ", + "SSL client key: ", + "SSL cert hostname: ", + "SSL SNI hostname: ", + "Min TLS version: ", + "Max TLS version: ", + "SSL ciphers: ", + "HTTP KeepAlive Timeout: 0", + "TCP KeepAlive Enabled: unset", + "TCP KeepAlive Interval: 0", + "TCP KeepAlive Probes: 0", + "TCP KeepAlive Timeout: 0", +}, "\n") + "\n" + +func updateBackendOK(i *fastly.UpdateBackendInput) (*fastly.Backend, error) { + return &fastly.Backend{ + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: i.NewName, + Comment: i.Comment, + }, nil +} + +func updateBackendError(_ *fastly.UpdateBackendInput) (*fastly.Backend, error) { + return nil, errTest +} + +func deleteBackendOK(_ *fastly.DeleteBackendInput) error { + return nil +} + +func deleteBackendError(_ *fastly.DeleteBackendInput) error { + return errTest +} diff --git a/pkg/commands/backend/create.go b/pkg/commands/backend/create.go new file mode 100644 index 000000000..d16c58afc --- /dev/null +++ b/pkg/commands/backend/create.go @@ -0,0 +1,308 @@ +package backend + +import ( + "errors" + "io" + "net" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// CreateCommand calls the Fastly API to create backends. +type CreateCommand struct { + argparser.Base + + // Required. + serviceVersion argparser.OptionalServiceVersion + + // Optional. + address argparser.OptionalString + autoClone argparser.OptionalAutoClone + autoLoadBalance argparser.OptionalBool + betweenBytesTimeout argparser.OptionalInt + comment argparser.OptionalString + connectTimeout argparser.OptionalInt + firstByteTimeout argparser.OptionalInt + healthCheck argparser.OptionalString + maxConn argparser.OptionalInt + maxTLSVersion argparser.OptionalString + minTLSVersion argparser.OptionalString + name argparser.OptionalString + noSSLCheckCert argparser.OptionalBool + overrideHost argparser.OptionalString + port argparser.OptionalInt + requestCondition argparser.OptionalString + serviceName argparser.OptionalServiceNameID + shield argparser.OptionalString + sslCACert argparser.OptionalString + sslCertHostname argparser.OptionalString + sslCheckCert argparser.OptionalBool + sslCiphers argparser.OptionalString + sslClientCert argparser.OptionalString + sslClientKey argparser.OptionalString + sslSNIHostname argparser.OptionalString + tcpKaEnable argparser.OptionalString + tcpKaInterval argparser.OptionalInt + tcpKaProbes argparser.OptionalInt + tcpKaTime argparser.OptionalInt + httpKaTime argparser.OptionalInt + useSSL argparser.OptionalBool + weight argparser.OptionalInt +} + +// NewCreateCommand returns a usable command registered under the parent. +func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { + c := CreateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("create", "Create a backend on a Fastly service version").Alias("add") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + + c.CmdClause.Flag("address", "A hostname, IPv4, or IPv6 address for the backend").Action(c.address.Set).StringVar(&c.address.Value) + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.autoClone.Set, + Dst: &c.autoClone.Value, + }) + c.CmdClause.Flag("auto-loadbalance", "Whether or not this backend should be automatically load balanced").Action(c.autoLoadBalance.Set).BoolVar(&c.autoLoadBalance.Value) + c.CmdClause.Flag("between-bytes-timeout", "How long to wait between bytes in milliseconds").Action(c.betweenBytesTimeout.Set).IntVar(&c.betweenBytesTimeout.Value) + c.CmdClause.Flag("comment", "A descriptive note").Action(c.comment.Set).StringVar(&c.comment.Value) + c.CmdClause.Flag("connect-timeout", "How long to wait for a timeout in milliseconds").Action(c.connectTimeout.Set).IntVar(&c.connectTimeout.Value) + c.CmdClause.Flag("first-byte-timeout", "How long to wait for the first bytes in milliseconds").Action(c.firstByteTimeout.Set).IntVar(&c.firstByteTimeout.Value) + c.CmdClause.Flag("healthcheck", "The name of the healthcheck to use with this backend").Action(c.healthCheck.Set).StringVar(&c.healthCheck.Value) + c.CmdClause.Flag("max-conn", "Maximum number of connections").Action(c.maxConn.Set).IntVar(&c.maxConn.Value) + c.CmdClause.Flag("max-tls-version", "Maximum allowed TLS version on SSL connections to this backend").Action(c.maxTLSVersion.Set).StringVar(&c.maxTLSVersion.Value) + c.CmdClause.Flag("min-tls-version", "Minimum allowed TLS version on SSL connections to this backend").Action(c.minTLSVersion.Set).StringVar(&c.minTLSVersion.Value) + c.CmdClause.Flag("name", "Backend name").Short('n').Action(c.name.Set).StringVar(&c.name.Value) + c.CmdClause.Flag("no-ssl-check-cert", "Skip checking SSL certs").Action(c.noSSLCheckCert.Set).BoolVar(&c.noSSLCheckCert.Value) + c.CmdClause.Flag("override-host", "The hostname to override the Host header").Action(c.overrideHost.Set).StringVar(&c.overrideHost.Value) + c.CmdClause.Flag("port", "Port number of the address").Action(c.port.Set).IntVar(&c.port.Value) + c.CmdClause.Flag("request-condition", "Condition, which if met, will select this backend during a request").Action(c.requestCondition.Set).StringVar(&c.requestCondition.Value) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + c.CmdClause.Flag("shield", "The shield POP designated to reduce inbound load on this origin by serving the cached data to the rest of the network").Action(c.shield.Set).StringVar(&c.shield.Value) + c.CmdClause.Flag("ssl-ca-cert", "CA certificate attached to origin").Action(c.sslCACert.Set).StringVar(&c.sslCACert.Value) + c.CmdClause.Flag("ssl-cert-hostname", "Overrides ssl_hostname, but only for cert verification. Does not affect SNI at all.").Action(c.sslCertHostname.Set).StringVar(&c.sslCertHostname.Value) + c.CmdClause.Flag("ssl-check-cert", "Be strict on checking SSL certs").Action(c.sslCheckCert.Set).BoolVar(&c.sslCheckCert.Value) + c.CmdClause.Flag("ssl-ciphers", "List of OpenSSL ciphers (https://www.openssl.org/docs/man1.0.2/man1/ciphers)").Action(c.sslCiphers.Set).StringVar(&c.sslCiphers.Value) + c.CmdClause.Flag("ssl-client-cert", "Client certificate attached to origin").Action(c.sslClientCert.Set).StringVar(&c.sslClientCert.Value) + c.CmdClause.Flag("ssl-client-key", "Client key attached to origin").Action(c.sslClientKey.Set).StringVar(&c.sslClientKey.Value) + c.CmdClause.Flag("ssl-sni-hostname", "Overrides ssl_hostname, but only for SNI in the handshake. Does not affect cert validation at all.").Action(c.sslSNIHostname.Set).StringVar(&c.sslSNIHostname.Value) + c.CmdClause.Flag("tcp-ka-enabled", "Enable TCP keepalive probes [true, false]").Action(c.tcpKaEnable.Set).StringVar(&c.tcpKaEnable.Value) + c.CmdClause.Flag("tcp-ka-interval", "Configure how long to wait between sending each TCP keepalive probe.").Action(c.tcpKaInterval.Set).IntVar(&c.tcpKaInterval.Value) + c.CmdClause.Flag("tcp-ka-probes", "Configure how many unacknowledged TCP keepalive probes to send before considering the connection dead.").Action(c.tcpKaProbes.Set).IntVar(&c.tcpKaProbes.Value) + c.CmdClause.Flag("tcp-ka-time", "Configure how long to wait after the last sent data before sending TCP keepalive probes.").Action(c.tcpKaTime.Set).IntVar(&c.tcpKaTime.Value) + c.CmdClause.Flag("http-ka-time", "Configure how long to keep idle HTTP keepalive connections in the connection pool.").Action(c.httpKaTime.Set).IntVar(&c.httpKaTime.Value) + c.CmdClause.Flag("use-ssl", "Whether or not to use SSL to reach the backend").Action(c.useSSL.Set).BoolVar(&c.useSSL.Value) + c.CmdClause.Flag("weight", "Weight used to load balance this backend against others").Action(c.weight.Set).IntVar(&c.weight.Value) + + return &c +} + +// Exec invokes the application logic for the command. +func (c *CreateCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.autoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fsterr.ServiceVersion(serviceVersion), + }) + return err + } + input := fastly.CreateBackendInput{ + ServiceID: serviceID, + ServiceVersion: fastly.ToValue(serviceVersion.Number), + } + + if c.name.WasSet { + input.Name = &c.name.Value + } + if c.address.WasSet { + input.Address = &c.address.Value + } + if c.autoLoadBalance.WasSet { + input.AutoLoadbalance = fastly.ToPointer(fastly.Compatibool(c.autoLoadBalance.Value)) + } + if c.betweenBytesTimeout.WasSet { + input.BetweenBytesTimeout = &c.betweenBytesTimeout.Value + } + if c.comment.WasSet { + input.Comment = &c.comment.Value + } + if c.connectTimeout.WasSet { + input.ConnectTimeout = &c.connectTimeout.Value + } + if c.firstByteTimeout.WasSet { + input.FirstByteTimeout = &c.firstByteTimeout.Value + } + if c.healthCheck.WasSet { + input.HealthCheck = &c.healthCheck.Value + } + if c.maxConn.WasSet { + input.MaxConn = &c.maxConn.Value + } + if c.maxTLSVersion.WasSet { + input.MaxTLSVersion = &c.maxTLSVersion.Value + } + if c.minTLSVersion.WasSet { + input.MinTLSVersion = &c.minTLSVersion.Value + } + if c.noSSLCheckCert.WasSet { + input.SSLCheckCert = fastly.ToPointer(fastly.Compatibool(false)) + } + if c.overrideHost.WasSet { + input.OverrideHost = &c.overrideHost.Value + } + if c.requestCondition.WasSet { + input.RequestCondition = &c.requestCondition.Value + } + if c.shield.WasSet { + input.Shield = &c.shield.Value + } + if c.sslCACert.WasSet { + input.SSLCACert = &c.sslCACert.Value + } + if c.sslCertHostname.WasSet { + input.SSLCertHostname = &c.sslCertHostname.Value + } + if c.sslCheckCert.WasSet { + text.Deprecated(out, "The Fastly API defaults `ssl_check_cert` to true. Use `--no-ssl-check-cert` to disable this setting.\n\n") + input.SSLCheckCert = fastly.ToPointer(fastly.Compatibool(c.sslCheckCert.Value)) + } + if c.sslCiphers.WasSet { + input.SSLCiphers = &c.sslCiphers.Value + } + if c.sslClientCert.WasSet { + input.SSLClientCert = &c.sslClientCert.Value + } + if c.sslClientKey.WasSet { + input.SSLClientKey = &c.sslClientKey.Value + } + if c.sslSNIHostname.WasSet { + input.SSLSNIHostname = &c.sslSNIHostname.Value + } + if c.tcpKaEnable.WasSet { + var tcpKaEnable bool + + switch c.tcpKaEnable.Value { + case "true": + tcpKaEnable = true + case "false": + tcpKaEnable = false + default: + err := errors.New("'tcp-ka-enable' flag must be one of the following [true, false]") + c.Globals.ErrLog.Add(err) + return err + } + + input.TCPKeepAliveEnable = &tcpKaEnable + } + if c.tcpKaInterval.WasSet { + input.TCPKeepAliveIntvl = &c.tcpKaInterval.Value + } + if c.tcpKaProbes.WasSet { + input.TCPKeepAliveProbes = &c.tcpKaProbes.Value + } + if c.tcpKaTime.WasSet { + input.TCPKeepAliveTime = &c.tcpKaTime.Value + } + if c.httpKaTime.WasSet { + input.KeepAliveTime = &c.httpKaTime.Value + } + if c.weight.WasSet { + input.Weight = &c.weight.Value + } + + switch { + case c.port.WasSet: + input.Port = &c.port.Value + case c.useSSL.WasSet && c.useSSL.Value: + if c.Globals.Flags.Verbose { + text.Warning(out, "Use-ssl was set but no port was specified, using default port 443\n\n") + } + input.Port = fastly.ToPointer(443) + } + + if input.Address != nil && !c.overrideHost.WasSet && !c.sslCertHostname.WasSet && !c.sslSNIHostname.WasSet { + overrideHost, sslSNIHostname, sslCertHostname := SetBackendHostDefaults(*input.Address) + if overrideHost != "" { + input.OverrideHost = &overrideHost + } + input.SSLSNIHostname = &sslSNIHostname + input.SSLCertHostname = &sslCertHostname + } else { + if c.overrideHost.WasSet { + input.OverrideHost = &c.overrideHost.Value + } + if c.sslCertHostname.WasSet { + input.SSLCertHostname = &c.sslCertHostname.Value + } + if c.sslSNIHostname.WasSet { + input.SSLSNIHostname = &c.sslSNIHostname.Value + } + } + + b, err := c.Globals.APIClient.CreateBackend(&input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": serviceVersion.Number, + }) + return err + } + + text.Success(out, "Created backend %s (service %s version %d)", fastly.ToValue(b.Name), fastly.ToValue(b.ServiceID), fastly.ToValue(b.ServiceVersion)) + return nil +} + +// SetBackendHostDefaults configures the OverrideHost and SSLSNIHostname fields. +// +// By default we set the override_host and ssl_sni_hostname properties of the +// Backend object to the hostname, unless the given input is an IP. +func SetBackendHostDefaults(address string) (overrideHost, sslSNIHostname, sslCertHostname string) { + if _, err := net.LookupAddr(address); err != nil { + overrideHost = address + } + if overrideHost != "" { + sslSNIHostname = overrideHost + sslCertHostname = overrideHost + } + return overrideHost, sslSNIHostname, sslCertHostname +} diff --git a/pkg/commands/backend/delete.go b/pkg/commands/backend/delete.go new file mode 100644 index 000000000..770279e9c --- /dev/null +++ b/pkg/commands/backend/delete.go @@ -0,0 +1,98 @@ +package backend + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// DeleteCommand calls the Fastly API to delete backends. +type DeleteCommand struct { + argparser.Base + Input fastly.DeleteBackendInput + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion + autoClone argparser.OptionalAutoClone +} + +// NewDeleteCommand returns a usable command registered under the parent. +func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { + c := DeleteCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("delete", "Delete a backend on a Fastly service version").Alias("remove") + + // Required. + c.CmdClause.Flag("name", "Backend name").Short('n').Required().StringVar(&c.Input.Name) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.autoClone.Set, + Dst: &c.autoClone.Value, + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + + return &c +} + +// Exec invokes the application logic for the command. +func (c *DeleteCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.autoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + c.Input.ServiceID = serviceID + c.Input.ServiceVersion = fastly.ToValue(serviceVersion.Number) + + if err := c.Globals.APIClient.DeleteBackend(&c.Input); err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": serviceVersion.Number, + }) + return err + } + + text.Success(out, "Deleted backend %s (service %s version %d)", c.Input.Name, c.Input.ServiceID, c.Input.ServiceVersion) + return nil +} diff --git a/pkg/commands/backend/describe.go b/pkg/commands/backend/describe.go new file mode 100644 index 000000000..8c9570f34 --- /dev/null +++ b/pkg/commands/backend/describe.go @@ -0,0 +1,140 @@ +package backend + +import ( + "fmt" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" +) + +// DescribeCommand calls the Fastly API to describe a backend. +type DescribeCommand struct { + argparser.Base + argparser.JSONOutput + + Input fastly.GetBackendInput + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion +} + +// NewDescribeCommand returns a usable command registered under the parent. +func NewDescribeCommand(parent argparser.Registerer, g *global.Data) *DescribeCommand { + c := DescribeCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("describe", "Show detailed information about a backend on a Fastly service version").Alias("get") + + // Required. + c.CmdClause.Flag("name", "Name of backend").Short('n').Required().StringVar(&c.Input.Name) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + return &c +} + +// Exec invokes the application logic for the command. +func (c *DescribeCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fsterr.ServiceVersion(serviceVersion), + }) + return err + } + + c.Input.ServiceID = serviceID + c.Input.ServiceVersion = fastly.ToValue(serviceVersion.Number) + + o, err := c.Globals.APIClient.GetBackend(&c.Input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": serviceVersion.Number, + }) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + return c.print(out, o) +} + +// print displays the information returned from the API. +func (c *DescribeCommand) print(out io.Writer, b *fastly.Backend) error { + if !c.Globals.Verbose() { + fmt.Fprintf(out, "\nService ID: %s\n", fastly.ToValue(b.ServiceID)) + } + fmt.Fprintf(out, "Service Version: %d\n\n", fastly.ToValue(b.ServiceVersion)) + fmt.Fprintf(out, "Name: %s\n", fastly.ToValue(b.Name)) + fmt.Fprintf(out, "Comment: %v\n", fastly.ToValue(b.Comment)) + fmt.Fprintf(out, "Address: %v\n", fastly.ToValue(b.Address)) + fmt.Fprintf(out, "Port: %v\n", fastly.ToValue(b.Port)) + fmt.Fprintf(out, "Override host: %v\n", fastly.ToValue(b.OverrideHost)) + fmt.Fprintf(out, "Connect timeout: %v\n", fastly.ToValue(b.ConnectTimeout)) + fmt.Fprintf(out, "Max connections: %v\n", fastly.ToValue(b.MaxConn)) + fmt.Fprintf(out, "First byte timeout: %v\n", fastly.ToValue(b.FirstByteTimeout)) + fmt.Fprintf(out, "Between bytes timeout: %v\n", fastly.ToValue(b.BetweenBytesTimeout)) + fmt.Fprintf(out, "Auto loadbalance: %v\n", fastly.ToValue(b.AutoLoadbalance)) + fmt.Fprintf(out, "Weight: %v\n", fastly.ToValue(b.Weight)) + fmt.Fprintf(out, "Healthcheck: %v\n", fastly.ToValue(b.HealthCheck)) + fmt.Fprintf(out, "Shield: %v\n", fastly.ToValue(b.Shield)) + fmt.Fprintf(out, "Use SSL: %v\n", fastly.ToValue(b.UseSSL)) + fmt.Fprintf(out, "SSL check cert: %v\n", fastly.ToValue(b.SSLCheckCert)) + fmt.Fprintf(out, "SSL CA cert: %v\n", fastly.ToValue(b.SSLCACert)) + fmt.Fprintf(out, "SSL client cert: %v\n", fastly.ToValue(b.SSLClientCert)) + fmt.Fprintf(out, "SSL client key: %v\n", fastly.ToValue(b.SSLClientKey)) + fmt.Fprintf(out, "SSL cert hostname: %v\n", fastly.ToValue(b.SSLCertHostname)) + fmt.Fprintf(out, "SSL SNI hostname: %v\n", fastly.ToValue(b.SSLSNIHostname)) + fmt.Fprintf(out, "Min TLS version: %v\n", fastly.ToValue(b.MinTLSVersion)) + fmt.Fprintf(out, "Max TLS version: %v\n", fastly.ToValue(b.MaxTLSVersion)) + fmt.Fprintf(out, "SSL ciphers: %v\n", fastly.ToValue(b.SSLCiphers)) + fmt.Fprintf(out, "HTTP KeepAlive Timeout: %v\n", fastly.ToValue(b.KeepAliveTime)) + if b.TCPKeepAliveEnable == nil { + fmt.Fprintf(out, "TCP KeepAlive Enabled: unset\n") + } else { + fmt.Fprintf(out, "TCP KeepAlive Enabled: %v\n", fastly.ToValue(b.TCPKeepAliveEnable)) + } + fmt.Fprintf(out, "TCP KeepAlive Interval: %v\n", fastly.ToValue(b.TCPKeepAliveIntvl)) + fmt.Fprintf(out, "TCP KeepAlive Probes: %v\n", fastly.ToValue(b.TCPKeepAliveProbes)) + fmt.Fprintf(out, "TCP KeepAlive Timeout: %v\n", fastly.ToValue(b.TCPKeepAliveTime)) + + return nil +} diff --git a/pkg/backend/doc.go b/pkg/commands/backend/doc.go similarity index 100% rename from pkg/backend/doc.go rename to pkg/commands/backend/doc.go diff --git a/pkg/commands/backend/list.go b/pkg/commands/backend/list.go new file mode 100644 index 000000000..b69c85d44 --- /dev/null +++ b/pkg/commands/backend/list.go @@ -0,0 +1,122 @@ +package backend + +import ( + "fmt" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// ListCommand calls the Fastly API to list backends. +type ListCommand struct { + argparser.Base + argparser.JSONOutput + + Input fastly.ListBackendsInput + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion +} + +// NewListCommand returns a usable command registered under the parent. +func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { + c := ListCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("list", "List backends on a Fastly service version") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + return &c +} + +// Exec invokes the application logic for the command. +func (c *ListCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fsterr.ServiceVersion(serviceVersion), + }) + return err + } + + c.Input.ServiceID = serviceID + c.Input.ServiceVersion = fastly.ToValue(serviceVersion.Number) + + o, err := c.Globals.APIClient.ListBackends(&c.Input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fastly.ToValue(serviceVersion.Number), + }) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + if !c.Globals.Verbose() { + tw := text.NewTable(out) + tw.AddHeader("SERVICE", "VERSION", "NAME", "ADDRESS", "PORT", "COMMENT") + for _, backend := range o { + tw.AddLine( + fastly.ToValue(backend.ServiceID), + fastly.ToValue(backend.ServiceVersion), + fastly.ToValue(backend.Name), + fastly.ToValue(backend.Address), + fastly.ToValue(backend.Port), + fastly.ToValue(backend.Comment), + ) + } + tw.Print() + return nil + } + + fmt.Fprintf(out, "Version: %d\n", c.Input.ServiceVersion) + for i, backend := range o { + fmt.Fprintf(out, "\tBackend %d/%d\n", i+1, len(o)) + text.PrintBackend(out, "\t\t", backend) + } + fmt.Fprintln(out) + + return nil +} diff --git a/pkg/commands/backend/root.go b/pkg/commands/backend/root.go new file mode 100644 index 000000000..3ce82799a --- /dev/null +++ b/pkg/commands/backend/root.go @@ -0,0 +1,31 @@ +package backend + +import ( + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" +) + +// RootCommand is the parent command for all subcommands in this package. +// It should be installed under the primary root command. +type RootCommand struct { + argparser.Base + // no flags +} + +// CommandName is the string to be used to invoke this command. +const CommandName = "backend" + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent argparser.Registerer, g *global.Data) *RootCommand { + var c RootCommand + c.Globals = g + c.CmdClause = parent.Command(CommandName, "Manipulate Fastly service version backends") + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { + panic("unreachable") +} diff --git a/pkg/commands/backend/update.go b/pkg/commands/backend/update.go new file mode 100644 index 000000000..06161b0f3 --- /dev/null +++ b/pkg/commands/backend/update.go @@ -0,0 +1,295 @@ +package backend + +import ( + "errors" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// UpdateCommand calls the Fastly API to update backends. +type UpdateCommand struct { + argparser.Base + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion + autoClone argparser.OptionalAutoClone + + name string + Address argparser.OptionalString + AutoLoadbalance argparser.OptionalBool + BetweenBytesTimeout argparser.OptionalInt + Comment argparser.OptionalString + ConnectTimeout argparser.OptionalInt + FirstByteTimeout argparser.OptionalInt + HealthCheck argparser.OptionalString + Hostname argparser.OptionalString + MaxConn argparser.OptionalInt + MaxTLSVersion argparser.OptionalString + MinTLSVersion argparser.OptionalString + NewName argparser.OptionalString + NoSSLCheckCert argparser.OptionalBool + OverrideHost argparser.OptionalString + Port argparser.OptionalInt + RequestCondition argparser.OptionalString + SSLCACert argparser.OptionalString + SSLCertHostname argparser.OptionalString + SSLCheckCert argparser.OptionalBool + SSLCiphers argparser.OptionalString + SSLClientCert argparser.OptionalString + SSLClientKey argparser.OptionalString + SSLSNIHostname argparser.OptionalString + Shield argparser.OptionalString + TCPKaEnable argparser.OptionalString + TCPKaInterval argparser.OptionalInt + TCPKaProbes argparser.OptionalInt + TCPKaTime argparser.OptionalInt + HTTPKaTime argparser.OptionalInt + UseSSL argparser.OptionalBool + Weight argparser.OptionalInt +} + +// NewUpdateCommand returns a usable command registered under the parent. +func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { + c := UpdateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("update", "Update a backend on a Fastly service version") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + c.CmdClause.Flag("name", "backend name").Short('n').Required().StringVar(&c.name) + + // Optional. + c.CmdClause.Flag("address", "A hostname, IPv4, or IPv6 address for the backend").Action(c.Address.Set).StringVar(&c.Address.Value) + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.autoClone.Set, + Dst: &c.autoClone.Value, + }) + c.CmdClause.Flag("auto-loadbalance", "Whether or not this backend should be automatically load balanced").Action(c.AutoLoadbalance.Set).BoolVar(&c.AutoLoadbalance.Value) + c.CmdClause.Flag("between-bytes-timeout", "How long to wait between bytes in milliseconds").Action(c.BetweenBytesTimeout.Set).IntVar(&c.BetweenBytesTimeout.Value) + c.CmdClause.Flag("comment", "A descriptive note").Action(c.Comment.Set).StringVar(&c.Comment.Value) + c.CmdClause.Flag("connect-timeout", "How long to wait for a timeout in milliseconds").Action(c.ConnectTimeout.Set).IntVar(&c.ConnectTimeout.Value) + c.CmdClause.Flag("first-byte-timeout", "How long to wait for the first bytes in milliseconds").Action(c.FirstByteTimeout.Set).IntVar(&c.FirstByteTimeout.Value) + c.CmdClause.Flag("healthcheck", "The name of the healthcheck to use with this backend").Action(c.HealthCheck.Set).StringVar(&c.HealthCheck.Value) + c.CmdClause.Flag("max-conn", "Maximum number of connections").Action(c.MaxConn.Set).IntVar(&c.MaxConn.Value) + c.CmdClause.Flag("max-tls-version", "Maximum allowed TLS version on SSL connections to this backend").Action(c.MaxTLSVersion.Set).StringVar(&c.MaxTLSVersion.Value) + c.CmdClause.Flag("min-tls-version", "Minimum allowed TLS version on SSL connections to this backend").Action(c.MinTLSVersion.Set).StringVar(&c.MinTLSVersion.Value) + c.CmdClause.Flag("new-name", "New backend name").Action(c.NewName.Set).StringVar(&c.NewName.Value) + c.CmdClause.Flag("no-ssl-check-cert", "Skip checking SSL certs").Action(c.NoSSLCheckCert.Set).BoolVar(&c.NoSSLCheckCert.Value) + c.CmdClause.Flag("override-host", "The hostname to override the Host header").Action(c.OverrideHost.Set).StringVar(&c.OverrideHost.Value) + c.CmdClause.Flag("port", "Port number of the address").Action(c.Port.Set).IntVar(&c.Port.Value) + c.CmdClause.Flag("request-condition", "condition, which if met, will select this backend during a request").Action(c.RequestCondition.Set).StringVar(&c.RequestCondition.Value) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + c.CmdClause.Flag("shield", "The shield POP designated to reduce inbound load on this origin by serving the cached data to the rest of the network").Action(c.Shield.Set).StringVar(&c.Shield.Value) + c.CmdClause.Flag("ssl-ca-cert", "CA certificate attached to origin").Action(c.SSLCACert.Set).StringVar(&c.SSLCACert.Value) + c.CmdClause.Flag("ssl-cert-hostname", "Overrides ssl_hostname, but only for cert verification. Does not affect SNI at all.").Action(c.SSLCertHostname.Set).StringVar(&c.SSLCertHostname.Value) + c.CmdClause.Flag("ssl-check-cert", "Be strict on checking SSL certs").Action(c.SSLCheckCert.Set).BoolVar(&c.SSLCheckCert.Value) + c.CmdClause.Flag("ssl-ciphers", "List of OpenSSL ciphers (https://www.openssl.org/docs/man1.0.2/man1/ciphers)").Action(c.SSLCiphers.Set).StringVar(&c.SSLCiphers.Value) + c.CmdClause.Flag("ssl-client-cert", "Client certificate attached to origin").Action(c.SSLClientCert.Set).StringVar(&c.SSLClientCert.Value) + c.CmdClause.Flag("ssl-client-key", "Client key attached to origin").Action(c.SSLClientKey.Set).StringVar(&c.SSLClientKey.Value) + c.CmdClause.Flag("ssl-sni-hostname", "Overrides ssl_hostname, but only for SNI in the handshake. Does not affect cert validation at all.").Action(c.SSLSNIHostname.Set).StringVar(&c.SSLSNIHostname.Value) + c.CmdClause.Flag("tcp-ka-enabled", "Enable TCP keepalive probes [true, false]").Action(c.TCPKaEnable.Set).StringVar(&c.TCPKaEnable.Value) + c.CmdClause.Flag("tcp-ka-interval", "Configure how long to wait between sending each TCP keepalive probe.").Action(c.TCPKaInterval.Set).IntVar(&c.TCPKaInterval.Value) + c.CmdClause.Flag("tcp-ka-probes", "Configure how many unacknowledged TCP keepalive probes to send before considering the connection dead.").Action(c.TCPKaProbes.Set).IntVar(&c.TCPKaProbes.Value) + c.CmdClause.Flag("tcp-ka-time", "Configure how long to wait after the last sent data before sending TCP keepalive probes.").Action(c.TCPKaTime.Set).IntVar(&c.TCPKaTime.Value) + c.CmdClause.Flag("http-ka-time", "Configure how long to keep idle HTTP keepalive connections in the connection pool.").Action(c.HTTPKaTime.Set).IntVar(&c.HTTPKaTime.Value) + c.CmdClause.Flag("use-ssl", "Whether or not to use SSL to reach the backend").Action(c.UseSSL.Set).BoolVar(&c.UseSSL.Value) + c.CmdClause.Flag("weight", "Weight used to load balance this backend against others").Action(c.Weight.Set).IntVar(&c.Weight.Value) + return &c +} + +// Exec invokes the application logic for the command. +func (c *UpdateCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.autoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fsterr.ServiceVersion(serviceVersion), + }) + return err + } + + input := &fastly.UpdateBackendInput{ + ServiceID: serviceID, + ServiceVersion: fastly.ToValue(serviceVersion.Number), + Name: c.name, + } + + if c.NewName.WasSet { + input.NewName = &c.NewName.Value + } + + if c.Comment.WasSet { + input.Comment = &c.Comment.Value + } + + if c.Address.WasSet { + input.Address = &c.Address.Value + } + + if c.Port.WasSet { + input.Port = &c.Port.Value + } + + if c.OverrideHost.WasSet { + input.OverrideHost = &c.OverrideHost.Value + } + + if c.ConnectTimeout.WasSet { + input.ConnectTimeout = &c.ConnectTimeout.Value + } + + if c.MaxConn.WasSet { + input.MaxConn = &c.MaxConn.Value + } + + if c.FirstByteTimeout.WasSet { + input.FirstByteTimeout = &c.FirstByteTimeout.Value + } + + if c.BetweenBytesTimeout.WasSet { + input.BetweenBytesTimeout = &c.BetweenBytesTimeout.Value + } + + if c.AutoLoadbalance.WasSet { + input.AutoLoadbalance = fastly.ToPointer(fastly.Compatibool(c.AutoLoadbalance.Value)) + } + + if c.Weight.WasSet { + input.Weight = &c.Weight.Value + } + + if c.RequestCondition.WasSet { + input.RequestCondition = &c.RequestCondition.Value + } + + if c.HealthCheck.WasSet { + input.HealthCheck = &c.HealthCheck.Value + } + + if c.Shield.WasSet { + input.Shield = &c.Shield.Value + } + + if c.UseSSL.WasSet { + input.UseSSL = fastly.ToPointer(fastly.Compatibool(c.UseSSL.Value)) + } + + if c.NoSSLCheckCert.WasSet { + input.SSLCheckCert = fastly.ToPointer(fastly.Compatibool(false)) + } + + if c.SSLCheckCert.WasSet { + text.Deprecated(out, "The Fastly API defaults `ssl_check_cert` to true. Use `--no-ssl-check-cert` to disable this setting.\n\n") + input.SSLCheckCert = fastly.ToPointer(fastly.Compatibool(c.SSLCheckCert.Value)) + } + + if c.SSLCACert.WasSet { + input.SSLCACert = &c.SSLCACert.Value + } + + if c.SSLClientCert.WasSet { + input.SSLClientCert = &c.SSLClientCert.Value + } + + if c.SSLClientKey.WasSet { + input.SSLClientKey = &c.SSLClientKey.Value + } + + if c.SSLCertHostname.WasSet { + input.SSLCertHostname = &c.SSLCertHostname.Value + } + + if c.SSLSNIHostname.WasSet { + input.SSLSNIHostname = &c.SSLSNIHostname.Value + } + + if c.MinTLSVersion.WasSet { + input.MinTLSVersion = &c.MinTLSVersion.Value + } + + if c.MaxTLSVersion.WasSet { + input.MaxTLSVersion = &c.MaxTLSVersion.Value + } + + if c.SSLCiphers.WasSet { + input.SSLCiphers = &c.SSLCiphers.Value + } + + if c.TCPKaEnable.WasSet { + var tcpKaEnable bool + + switch c.TCPKaEnable.Value { + case "true": + tcpKaEnable = true + case "false": + tcpKaEnable = false + default: + err := errors.New("'tcp-ka-enable' flag must be one of the following [true, false]") + c.Globals.ErrLog.Add(err) + return err + } + + input.TCPKeepAliveEnable = &tcpKaEnable + } + if c.TCPKaInterval.WasSet { + input.TCPKeepAliveIntvl = &c.TCPKaInterval.Value + } + if c.TCPKaProbes.WasSet { + input.TCPKeepAliveProbes = &c.TCPKaProbes.Value + } + if c.TCPKaTime.WasSet { + input.TCPKeepAliveTime = &c.TCPKaTime.Value + } + + if c.HTTPKaTime.WasSet { + input.KeepAliveTime = &c.HTTPKaTime.Value + } + + b, err := c.Globals.APIClient.UpdateBackend(input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": serviceVersion.Number, + }) + return err + } + + text.Success(out, "Updated backend %s (service %s version %d)", fastly.ToValue(b.Name), fastly.ToValue(b.ServiceID), fastly.ToValue(b.ServiceVersion)) + return nil +} diff --git a/pkg/commands/commands.go b/pkg/commands/commands.go new file mode 100644 index 000000000..6b188cc02 --- /dev/null +++ b/pkg/commands/commands.go @@ -0,0 +1,927 @@ +package commands + +import ( + "github.com/fastly/kingpin" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/acl" + "github.com/fastly/cli/pkg/commands/aclentry" + "github.com/fastly/cli/pkg/commands/alerts" + "github.com/fastly/cli/pkg/commands/authtoken" + "github.com/fastly/cli/pkg/commands/backend" + "github.com/fastly/cli/pkg/commands/compute" + "github.com/fastly/cli/pkg/commands/compute/computeacl" + "github.com/fastly/cli/pkg/commands/config" + "github.com/fastly/cli/pkg/commands/configstore" + "github.com/fastly/cli/pkg/commands/configstoreentry" + "github.com/fastly/cli/pkg/commands/dashboard" + dashboardItem "github.com/fastly/cli/pkg/commands/dashboard/item" + "github.com/fastly/cli/pkg/commands/dictionary" + "github.com/fastly/cli/pkg/commands/dictionaryentry" + "github.com/fastly/cli/pkg/commands/domain" + "github.com/fastly/cli/pkg/commands/domainv1" + "github.com/fastly/cli/pkg/commands/healthcheck" + "github.com/fastly/cli/pkg/commands/install" + "github.com/fastly/cli/pkg/commands/ip" + "github.com/fastly/cli/pkg/commands/kvstore" + "github.com/fastly/cli/pkg/commands/kvstoreentry" + "github.com/fastly/cli/pkg/commands/logging" + "github.com/fastly/cli/pkg/commands/logging/azureblob" + "github.com/fastly/cli/pkg/commands/logging/bigquery" + "github.com/fastly/cli/pkg/commands/logging/cloudfiles" + "github.com/fastly/cli/pkg/commands/logging/datadog" + "github.com/fastly/cli/pkg/commands/logging/digitalocean" + "github.com/fastly/cli/pkg/commands/logging/elasticsearch" + "github.com/fastly/cli/pkg/commands/logging/ftp" + "github.com/fastly/cli/pkg/commands/logging/gcs" + "github.com/fastly/cli/pkg/commands/logging/googlepubsub" + "github.com/fastly/cli/pkg/commands/logging/grafanacloudlogs" + "github.com/fastly/cli/pkg/commands/logging/heroku" + "github.com/fastly/cli/pkg/commands/logging/honeycomb" + "github.com/fastly/cli/pkg/commands/logging/https" + "github.com/fastly/cli/pkg/commands/logging/kafka" + "github.com/fastly/cli/pkg/commands/logging/kinesis" + "github.com/fastly/cli/pkg/commands/logging/loggly" + "github.com/fastly/cli/pkg/commands/logging/logshuttle" + "github.com/fastly/cli/pkg/commands/logging/newrelic" + "github.com/fastly/cli/pkg/commands/logging/newrelicotlp" + "github.com/fastly/cli/pkg/commands/logging/openstack" + "github.com/fastly/cli/pkg/commands/logging/papertrail" + "github.com/fastly/cli/pkg/commands/logging/s3" + "github.com/fastly/cli/pkg/commands/logging/scalyr" + "github.com/fastly/cli/pkg/commands/logging/sftp" + "github.com/fastly/cli/pkg/commands/logging/splunk" + "github.com/fastly/cli/pkg/commands/logging/sumologic" + "github.com/fastly/cli/pkg/commands/logging/syslog" + "github.com/fastly/cli/pkg/commands/logtail" + "github.com/fastly/cli/pkg/commands/objectstorage" + "github.com/fastly/cli/pkg/commands/objectstorage/accesskeys" + "github.com/fastly/cli/pkg/commands/pop" + "github.com/fastly/cli/pkg/commands/products" + "github.com/fastly/cli/pkg/commands/profile" + "github.com/fastly/cli/pkg/commands/purge" + "github.com/fastly/cli/pkg/commands/ratelimit" + "github.com/fastly/cli/pkg/commands/resourcelink" + "github.com/fastly/cli/pkg/commands/secretstore" + "github.com/fastly/cli/pkg/commands/secretstoreentry" + "github.com/fastly/cli/pkg/commands/service" + "github.com/fastly/cli/pkg/commands/serviceauth" + "github.com/fastly/cli/pkg/commands/serviceversion" + "github.com/fastly/cli/pkg/commands/shellcomplete" + "github.com/fastly/cli/pkg/commands/sso" + "github.com/fastly/cli/pkg/commands/stats" + tlsconfig "github.com/fastly/cli/pkg/commands/tls/config" + tlscustom "github.com/fastly/cli/pkg/commands/tls/custom" + tlscustomactivation "github.com/fastly/cli/pkg/commands/tls/custom/activation" + tlscustomcertificate "github.com/fastly/cli/pkg/commands/tls/custom/certificate" + tlscustomdomain "github.com/fastly/cli/pkg/commands/tls/custom/domain" + tlscustomprivatekey "github.com/fastly/cli/pkg/commands/tls/custom/privatekey" + tlsplatform "github.com/fastly/cli/pkg/commands/tls/platform" + tlssubscription "github.com/fastly/cli/pkg/commands/tls/subscription" + "github.com/fastly/cli/pkg/commands/update" + "github.com/fastly/cli/pkg/commands/user" + "github.com/fastly/cli/pkg/commands/vcl" + "github.com/fastly/cli/pkg/commands/vcl/condition" + "github.com/fastly/cli/pkg/commands/vcl/custom" + "github.com/fastly/cli/pkg/commands/vcl/snippet" + "github.com/fastly/cli/pkg/commands/version" + "github.com/fastly/cli/pkg/commands/whoami" + "github.com/fastly/cli/pkg/global" +) + +// Define constructs all the commands exposed by the CLI. +func Define( // nolint:revive // function-length + app *kingpin.Application, + data *global.Data, +) []argparser.Command { + shellcompleteCmdRoot := shellcomplete.NewRootCommand(app, data) + + // NOTE: The order commands are created are the order they appear in 'help'. + // But because we need to pass the SSO command into the profile commands, it + // means the SSO command must be created _before_ the profile commands. This + // messes up the order of the commands in the `--help` output. So to make the + // placement of the `sso` subcommand not look too odd we place it at the + // beginning of the list of commands. + ssoCmdRoot := sso.NewRootCommand(app, data) + + aclCmdRoot := acl.NewRootCommand(app, data) + aclCreate := acl.NewCreateCommand(aclCmdRoot.CmdClause, data) + aclDelete := acl.NewDeleteCommand(aclCmdRoot.CmdClause, data) + aclDescribe := acl.NewDescribeCommand(aclCmdRoot.CmdClause, data) + aclList := acl.NewListCommand(aclCmdRoot.CmdClause, data) + aclUpdate := acl.NewUpdateCommand(aclCmdRoot.CmdClause, data) + aclEntryCmdRoot := aclentry.NewRootCommand(app, data) + aclEntryCreate := aclentry.NewCreateCommand(aclEntryCmdRoot.CmdClause, data) + aclEntryDelete := aclentry.NewDeleteCommand(aclEntryCmdRoot.CmdClause, data) + aclEntryDescribe := aclentry.NewDescribeCommand(aclEntryCmdRoot.CmdClause, data) + aclEntryList := aclentry.NewListCommand(aclEntryCmdRoot.CmdClause, data) + aclEntryUpdate := aclentry.NewUpdateCommand(aclEntryCmdRoot.CmdClause, data) + alertsCmdRoot := alerts.NewRootCommand(app, data) + alertsCreate := alerts.NewCreateCommand(alertsCmdRoot.CmdClause, data) + alertsDelete := alerts.NewDeleteCommand(alertsCmdRoot.CmdClause, data) + alertsDescribe := alerts.NewDescribeCommand(alertsCmdRoot.CmdClause, data) + alertsList := alerts.NewListCommand(alertsCmdRoot.CmdClause, data) + alertsListHistory := alerts.NewListHistoryCommand(alertsCmdRoot.CmdClause, data) + alertsUpdate := alerts.NewUpdateCommand(alertsCmdRoot.CmdClause, data) + authtokenCmdRoot := authtoken.NewRootCommand(app, data) + authtokenCreate := authtoken.NewCreateCommand(authtokenCmdRoot.CmdClause, data) + authtokenDelete := authtoken.NewDeleteCommand(authtokenCmdRoot.CmdClause, data) + authtokenDescribe := authtoken.NewDescribeCommand(authtokenCmdRoot.CmdClause, data) + authtokenList := authtoken.NewListCommand(authtokenCmdRoot.CmdClause, data) + backendCmdRoot := backend.NewRootCommand(app, data) + backendCreate := backend.NewCreateCommand(backendCmdRoot.CmdClause, data) + backendDelete := backend.NewDeleteCommand(backendCmdRoot.CmdClause, data) + backendDescribe := backend.NewDescribeCommand(backendCmdRoot.CmdClause, data) + backendList := backend.NewListCommand(backendCmdRoot.CmdClause, data) + backendUpdate := backend.NewUpdateCommand(backendCmdRoot.CmdClause, data) + computeCmdRoot := compute.NewRootCommand(app, data) + computeACLCmdRoot := computeacl.NewRootCommand(computeCmdRoot.CmdClause, data) + computeACLCreate := computeacl.NewCreateCommand(computeACLCmdRoot.CmdClause, data) + computeACLList := computeacl.NewListCommand(computeACLCmdRoot.CmdClause, data) + computeACLDescribe := computeacl.NewDescribeCommand(computeACLCmdRoot.CmdClause, data) + computeACLUpdate := computeacl.NewUpdateCommand(computeACLCmdRoot.CmdClause, data) + computeACLLookup := computeacl.NewLookupCommand(computeACLCmdRoot.CmdClause, data) + computeACLDelete := computeacl.NewDeleteCommand(computeACLCmdRoot.CmdClause, data) + computeACLEntriesList := computeacl.NewListEntriesCommand(computeACLCmdRoot.CmdClause, data) + computeBuild := compute.NewBuildCommand(computeCmdRoot.CmdClause, data) + computeDeploy := compute.NewDeployCommand(computeCmdRoot.CmdClause, data) + computeHashFiles := compute.NewHashFilesCommand(computeCmdRoot.CmdClause, data, computeBuild) + computeHashsum := compute.NewHashsumCommand(computeCmdRoot.CmdClause, data, computeBuild) + computeInit := compute.NewInitCommand(computeCmdRoot.CmdClause, data) + computeMetadata := compute.NewMetadataCommand(computeCmdRoot.CmdClause, data) + computePack := compute.NewPackCommand(computeCmdRoot.CmdClause, data) + computePublish := compute.NewPublishCommand(computeCmdRoot.CmdClause, data, computeBuild, computeDeploy) + computeServe := compute.NewServeCommand(computeCmdRoot.CmdClause, data, computeBuild) + computeUpdate := compute.NewUpdateCommand(computeCmdRoot.CmdClause, data) + computeValidate := compute.NewValidateCommand(computeCmdRoot.CmdClause, data) + configCmdRoot := config.NewRootCommand(app, data) + configstoreCmdRoot := configstore.NewRootCommand(app, data) + configstoreCreate := configstore.NewCreateCommand(configstoreCmdRoot.CmdClause, data) + configstoreDelete := configstore.NewDeleteCommand(configstoreCmdRoot.CmdClause, data) + configstoreDescribe := configstore.NewDescribeCommand(configstoreCmdRoot.CmdClause, data) + configstoreList := configstore.NewListCommand(configstoreCmdRoot.CmdClause, data) + configstoreListServices := configstore.NewListServicesCommand(configstoreCmdRoot.CmdClause, data) + configstoreUpdate := configstore.NewUpdateCommand(configstoreCmdRoot.CmdClause, data) + configstoreentryCmdRoot := configstoreentry.NewRootCommand(app, data) + configstoreentryCreate := configstoreentry.NewCreateCommand(configstoreentryCmdRoot.CmdClause, data) + configstoreentryDelete := configstoreentry.NewDeleteCommand(configstoreentryCmdRoot.CmdClause, data) + configstoreentryDescribe := configstoreentry.NewDescribeCommand(configstoreentryCmdRoot.CmdClause, data) + configstoreentryList := configstoreentry.NewListCommand(configstoreentryCmdRoot.CmdClause, data) + configstoreentryUpdate := configstoreentry.NewUpdateCommand(configstoreentryCmdRoot.CmdClause, data) + dashboardCmdRoot := dashboard.NewRootCommand(app, data) + dashboardList := dashboard.NewListCommand(dashboardCmdRoot.CmdClause, data) + dashboardCreate := dashboard.NewCreateCommand(dashboardCmdRoot.CmdClause, data) + dashboardDescribe := dashboard.NewDescribeCommand(dashboardCmdRoot.CmdClause, data) + dashboardUpdate := dashboard.NewUpdateCommand(dashboardCmdRoot.CmdClause, data) + dashboardDelete := dashboard.NewDeleteCommand(dashboardCmdRoot.CmdClause, data) + dashboardItemCmdRoot := dashboardItem.NewRootCommand(dashboardCmdRoot.CmdClause, data) + dashboardItemCreate := dashboardItem.NewCreateCommand(dashboardItemCmdRoot.CmdClause, data) + dashboardItemDescribe := dashboardItem.NewDescribeCommand(dashboardItemCmdRoot.CmdClause, data) + dashboardItemUpdate := dashboardItem.NewUpdateCommand(dashboardItemCmdRoot.CmdClause, data) + dashboardItemDelete := dashboardItem.NewDeleteCommand(dashboardItemCmdRoot.CmdClause, data) + dictionaryCmdRoot := dictionary.NewRootCommand(app, data) + dictionaryCreate := dictionary.NewCreateCommand(dictionaryCmdRoot.CmdClause, data) + dictionaryDelete := dictionary.NewDeleteCommand(dictionaryCmdRoot.CmdClause, data) + dictionaryDescribe := dictionary.NewDescribeCommand(dictionaryCmdRoot.CmdClause, data) + dictionaryEntryCmdRoot := dictionaryentry.NewRootCommand(app, data) + dictionaryEntryCreate := dictionaryentry.NewCreateCommand(dictionaryEntryCmdRoot.CmdClause, data) + dictionaryEntryDelete := dictionaryentry.NewDeleteCommand(dictionaryEntryCmdRoot.CmdClause, data) + dictionaryEntryDescribe := dictionaryentry.NewDescribeCommand(dictionaryEntryCmdRoot.CmdClause, data) + dictionaryEntryList := dictionaryentry.NewListCommand(dictionaryEntryCmdRoot.CmdClause, data) + dictionaryEntryUpdate := dictionaryentry.NewUpdateCommand(dictionaryEntryCmdRoot.CmdClause, data) + dictionaryList := dictionary.NewListCommand(dictionaryCmdRoot.CmdClause, data) + dictionaryUpdate := dictionary.NewUpdateCommand(dictionaryCmdRoot.CmdClause, data) + domainCmdRoot := domain.NewRootCommand(app, data) + domainCreate := domain.NewCreateCommand(domainCmdRoot.CmdClause, data) + domainDelete := domain.NewDeleteCommand(domainCmdRoot.CmdClause, data) + domainDescribe := domain.NewDescribeCommand(domainCmdRoot.CmdClause, data) + domainList := domain.NewListCommand(domainCmdRoot.CmdClause, data) + domainUpdate := domain.NewUpdateCommand(domainCmdRoot.CmdClause, data) + domainValidate := domain.NewValidateCommand(domainCmdRoot.CmdClause, data) + domainv1CmdRoot := domainv1.NewRootCommand(app, data) + domainv1Create := domainv1.NewCreateCommand(domainv1CmdRoot.CmdClause, data) + domainv1Delete := domainv1.NewDeleteCommand(domainv1CmdRoot.CmdClause, data) + domainv1Describe := domainv1.NewDescribeCommand(domainv1CmdRoot.CmdClause, data) + domainv1List := domainv1.NewListCommand(domainv1CmdRoot.CmdClause, data) + domainv1Update := domainv1.NewUpdateCommand(domainv1CmdRoot.CmdClause, data) + healthcheckCmdRoot := healthcheck.NewRootCommand(app, data) + healthcheckCreate := healthcheck.NewCreateCommand(healthcheckCmdRoot.CmdClause, data) + healthcheckDelete := healthcheck.NewDeleteCommand(healthcheckCmdRoot.CmdClause, data) + healthcheckDescribe := healthcheck.NewDescribeCommand(healthcheckCmdRoot.CmdClause, data) + healthcheckList := healthcheck.NewListCommand(healthcheckCmdRoot.CmdClause, data) + healthcheckUpdate := healthcheck.NewUpdateCommand(healthcheckCmdRoot.CmdClause, data) + installRoot := install.NewRootCommand(app, data) + ipCmdRoot := ip.NewRootCommand(app, data) + kvstoreCmdRoot := kvstore.NewRootCommand(app, data) + kvstoreCreate := kvstore.NewCreateCommand(kvstoreCmdRoot.CmdClause, data) + kvstoreDelete := kvstore.NewDeleteCommand(kvstoreCmdRoot.CmdClause, data) + kvstoreDescribe := kvstore.NewDescribeCommand(kvstoreCmdRoot.CmdClause, data) + kvstoreList := kvstore.NewListCommand(kvstoreCmdRoot.CmdClause, data) + kvstoreentryCmdRoot := kvstoreentry.NewRootCommand(app, data) + kvstoreentryCreate := kvstoreentry.NewCreateCommand(kvstoreentryCmdRoot.CmdClause, data) + kvstoreentryDelete := kvstoreentry.NewDeleteCommand(kvstoreentryCmdRoot.CmdClause, data) + kvstoreentryDescribe := kvstoreentry.NewDescribeCommand(kvstoreentryCmdRoot.CmdClause, data) + kvstoreentryList := kvstoreentry.NewListCommand(kvstoreentryCmdRoot.CmdClause, data) + logtailCmdRoot := logtail.NewRootCommand(app, data) + loggingCmdRoot := logging.NewRootCommand(app, data) + loggingAzureblobCmdRoot := azureblob.NewRootCommand(loggingCmdRoot.CmdClause, data) + loggingAzureblobCreate := azureblob.NewCreateCommand(loggingAzureblobCmdRoot.CmdClause, data) + loggingAzureblobDelete := azureblob.NewDeleteCommand(loggingAzureblobCmdRoot.CmdClause, data) + loggingAzureblobDescribe := azureblob.NewDescribeCommand(loggingAzureblobCmdRoot.CmdClause, data) + loggingAzureblobList := azureblob.NewListCommand(loggingAzureblobCmdRoot.CmdClause, data) + loggingAzureblobUpdate := azureblob.NewUpdateCommand(loggingAzureblobCmdRoot.CmdClause, data) + loggingBigQueryCmdRoot := bigquery.NewRootCommand(loggingCmdRoot.CmdClause, data) + loggingBigQueryCreate := bigquery.NewCreateCommand(loggingBigQueryCmdRoot.CmdClause, data) + loggingBigQueryDelete := bigquery.NewDeleteCommand(loggingBigQueryCmdRoot.CmdClause, data) + loggingBigQueryDescribe := bigquery.NewDescribeCommand(loggingBigQueryCmdRoot.CmdClause, data) + loggingBigQueryList := bigquery.NewListCommand(loggingBigQueryCmdRoot.CmdClause, data) + loggingBigQueryUpdate := bigquery.NewUpdateCommand(loggingBigQueryCmdRoot.CmdClause, data) + loggingCloudfilesCmdRoot := cloudfiles.NewRootCommand(loggingCmdRoot.CmdClause, data) + loggingCloudfilesCreate := cloudfiles.NewCreateCommand(loggingCloudfilesCmdRoot.CmdClause, data) + loggingCloudfilesDelete := cloudfiles.NewDeleteCommand(loggingCloudfilesCmdRoot.CmdClause, data) + loggingCloudfilesDescribe := cloudfiles.NewDescribeCommand(loggingCloudfilesCmdRoot.CmdClause, data) + loggingCloudfilesList := cloudfiles.NewListCommand(loggingCloudfilesCmdRoot.CmdClause, data) + loggingCloudfilesUpdate := cloudfiles.NewUpdateCommand(loggingCloudfilesCmdRoot.CmdClause, data) + loggingDatadogCmdRoot := datadog.NewRootCommand(loggingCmdRoot.CmdClause, data) + loggingDatadogCreate := datadog.NewCreateCommand(loggingDatadogCmdRoot.CmdClause, data) + loggingDatadogDelete := datadog.NewDeleteCommand(loggingDatadogCmdRoot.CmdClause, data) + loggingDatadogDescribe := datadog.NewDescribeCommand(loggingDatadogCmdRoot.CmdClause, data) + loggingDatadogList := datadog.NewListCommand(loggingDatadogCmdRoot.CmdClause, data) + loggingDatadogUpdate := datadog.NewUpdateCommand(loggingDatadogCmdRoot.CmdClause, data) + loggingDigitaloceanCmdRoot := digitalocean.NewRootCommand(loggingCmdRoot.CmdClause, data) + loggingDigitaloceanCreate := digitalocean.NewCreateCommand(loggingDigitaloceanCmdRoot.CmdClause, data) + loggingDigitaloceanDelete := digitalocean.NewDeleteCommand(loggingDigitaloceanCmdRoot.CmdClause, data) + loggingDigitaloceanDescribe := digitalocean.NewDescribeCommand(loggingDigitaloceanCmdRoot.CmdClause, data) + loggingDigitaloceanList := digitalocean.NewListCommand(loggingDigitaloceanCmdRoot.CmdClause, data) + loggingDigitaloceanUpdate := digitalocean.NewUpdateCommand(loggingDigitaloceanCmdRoot.CmdClause, data) + loggingElasticsearchCmdRoot := elasticsearch.NewRootCommand(loggingCmdRoot.CmdClause, data) + loggingElasticsearchCreate := elasticsearch.NewCreateCommand(loggingElasticsearchCmdRoot.CmdClause, data) + loggingElasticsearchDelete := elasticsearch.NewDeleteCommand(loggingElasticsearchCmdRoot.CmdClause, data) + loggingElasticsearchDescribe := elasticsearch.NewDescribeCommand(loggingElasticsearchCmdRoot.CmdClause, data) + loggingElasticsearchList := elasticsearch.NewListCommand(loggingElasticsearchCmdRoot.CmdClause, data) + loggingElasticsearchUpdate := elasticsearch.NewUpdateCommand(loggingElasticsearchCmdRoot.CmdClause, data) + loggingFtpCmdRoot := ftp.NewRootCommand(loggingCmdRoot.CmdClause, data) + loggingFtpCreate := ftp.NewCreateCommand(loggingFtpCmdRoot.CmdClause, data) + loggingFtpDelete := ftp.NewDeleteCommand(loggingFtpCmdRoot.CmdClause, data) + loggingFtpDescribe := ftp.NewDescribeCommand(loggingFtpCmdRoot.CmdClause, data) + loggingFtpList := ftp.NewListCommand(loggingFtpCmdRoot.CmdClause, data) + loggingFtpUpdate := ftp.NewUpdateCommand(loggingFtpCmdRoot.CmdClause, data) + loggingGcsCmdRoot := gcs.NewRootCommand(loggingCmdRoot.CmdClause, data) + loggingGcsCreate := gcs.NewCreateCommand(loggingGcsCmdRoot.CmdClause, data) + loggingGcsDelete := gcs.NewDeleteCommand(loggingGcsCmdRoot.CmdClause, data) + loggingGcsDescribe := gcs.NewDescribeCommand(loggingGcsCmdRoot.CmdClause, data) + loggingGcsList := gcs.NewListCommand(loggingGcsCmdRoot.CmdClause, data) + loggingGcsUpdate := gcs.NewUpdateCommand(loggingGcsCmdRoot.CmdClause, data) + loggingGooglepubsubCmdRoot := googlepubsub.NewRootCommand(loggingCmdRoot.CmdClause, data) + loggingGooglepubsubCreate := googlepubsub.NewCreateCommand(loggingGooglepubsubCmdRoot.CmdClause, data) + loggingGooglepubsubDelete := googlepubsub.NewDeleteCommand(loggingGooglepubsubCmdRoot.CmdClause, data) + loggingGooglepubsubDescribe := googlepubsub.NewDescribeCommand(loggingGooglepubsubCmdRoot.CmdClause, data) + loggingGooglepubsubList := googlepubsub.NewListCommand(loggingGooglepubsubCmdRoot.CmdClause, data) + loggingGooglepubsubUpdate := googlepubsub.NewUpdateCommand(loggingGooglepubsubCmdRoot.CmdClause, data) + loggingGrafanacloudlogsCmdRoot := grafanacloudlogs.NewRootCommand(loggingCmdRoot.CmdClause, data) + loggingGrafanacloudlogsCreate := grafanacloudlogs.NewCreateCommand(loggingGrafanacloudlogsCmdRoot.CmdClause, data) + loggingGrafanacloudlogsDelete := grafanacloudlogs.NewDeleteCommand(loggingGrafanacloudlogsCmdRoot.CmdClause, data) + loggingGrafanacloudlogsDescribe := grafanacloudlogs.NewDescribeCommand(loggingGrafanacloudlogsCmdRoot.CmdClause, data) + loggingGrafanacloudlogsList := grafanacloudlogs.NewListCommand(loggingGrafanacloudlogsCmdRoot.CmdClause, data) + loggingGrafanacloudlogsUpdate := grafanacloudlogs.NewUpdateCommand(loggingGrafanacloudlogsCmdRoot.CmdClause, data) + loggingHerokuCmdRoot := heroku.NewRootCommand(loggingCmdRoot.CmdClause, data) + loggingHerokuCreate := heroku.NewCreateCommand(loggingHerokuCmdRoot.CmdClause, data) + loggingHerokuDelete := heroku.NewDeleteCommand(loggingHerokuCmdRoot.CmdClause, data) + loggingHerokuDescribe := heroku.NewDescribeCommand(loggingHerokuCmdRoot.CmdClause, data) + loggingHerokuList := heroku.NewListCommand(loggingHerokuCmdRoot.CmdClause, data) + loggingHerokuUpdate := heroku.NewUpdateCommand(loggingHerokuCmdRoot.CmdClause, data) + loggingHoneycombCmdRoot := honeycomb.NewRootCommand(loggingCmdRoot.CmdClause, data) + loggingHoneycombCreate := honeycomb.NewCreateCommand(loggingHoneycombCmdRoot.CmdClause, data) + loggingHoneycombDelete := honeycomb.NewDeleteCommand(loggingHoneycombCmdRoot.CmdClause, data) + loggingHoneycombDescribe := honeycomb.NewDescribeCommand(loggingHoneycombCmdRoot.CmdClause, data) + loggingHoneycombList := honeycomb.NewListCommand(loggingHoneycombCmdRoot.CmdClause, data) + loggingHoneycombUpdate := honeycomb.NewUpdateCommand(loggingHoneycombCmdRoot.CmdClause, data) + loggingHTTPSCmdRoot := https.NewRootCommand(loggingCmdRoot.CmdClause, data) + loggingHTTPSCreate := https.NewCreateCommand(loggingHTTPSCmdRoot.CmdClause, data) + loggingHTTPSDelete := https.NewDeleteCommand(loggingHTTPSCmdRoot.CmdClause, data) + loggingHTTPSDescribe := https.NewDescribeCommand(loggingHTTPSCmdRoot.CmdClause, data) + loggingHTTPSList := https.NewListCommand(loggingHTTPSCmdRoot.CmdClause, data) + loggingHTTPSUpdate := https.NewUpdateCommand(loggingHTTPSCmdRoot.CmdClause, data) + loggingKafkaCmdRoot := kafka.NewRootCommand(loggingCmdRoot.CmdClause, data) + loggingKafkaCreate := kafka.NewCreateCommand(loggingKafkaCmdRoot.CmdClause, data) + loggingKafkaDelete := kafka.NewDeleteCommand(loggingKafkaCmdRoot.CmdClause, data) + loggingKafkaDescribe := kafka.NewDescribeCommand(loggingKafkaCmdRoot.CmdClause, data) + loggingKafkaList := kafka.NewListCommand(loggingKafkaCmdRoot.CmdClause, data) + loggingKafkaUpdate := kafka.NewUpdateCommand(loggingKafkaCmdRoot.CmdClause, data) + loggingKinesisCmdRoot := kinesis.NewRootCommand(loggingCmdRoot.CmdClause, data) + loggingKinesisCreate := kinesis.NewCreateCommand(loggingKinesisCmdRoot.CmdClause, data) + loggingKinesisDelete := kinesis.NewDeleteCommand(loggingKinesisCmdRoot.CmdClause, data) + loggingKinesisDescribe := kinesis.NewDescribeCommand(loggingKinesisCmdRoot.CmdClause, data) + loggingKinesisList := kinesis.NewListCommand(loggingKinesisCmdRoot.CmdClause, data) + loggingKinesisUpdate := kinesis.NewUpdateCommand(loggingKinesisCmdRoot.CmdClause, data) + loggingLogglyCmdRoot := loggly.NewRootCommand(loggingCmdRoot.CmdClause, data) + loggingLogglyCreate := loggly.NewCreateCommand(loggingLogglyCmdRoot.CmdClause, data) + loggingLogglyDelete := loggly.NewDeleteCommand(loggingLogglyCmdRoot.CmdClause, data) + loggingLogglyDescribe := loggly.NewDescribeCommand(loggingLogglyCmdRoot.CmdClause, data) + loggingLogglyList := loggly.NewListCommand(loggingLogglyCmdRoot.CmdClause, data) + loggingLogglyUpdate := loggly.NewUpdateCommand(loggingLogglyCmdRoot.CmdClause, data) + loggingLogshuttleCmdRoot := logshuttle.NewRootCommand(loggingCmdRoot.CmdClause, data) + loggingLogshuttleCreate := logshuttle.NewCreateCommand(loggingLogshuttleCmdRoot.CmdClause, data) + loggingLogshuttleDelete := logshuttle.NewDeleteCommand(loggingLogshuttleCmdRoot.CmdClause, data) + loggingLogshuttleDescribe := logshuttle.NewDescribeCommand(loggingLogshuttleCmdRoot.CmdClause, data) + loggingLogshuttleList := logshuttle.NewListCommand(loggingLogshuttleCmdRoot.CmdClause, data) + loggingLogshuttleUpdate := logshuttle.NewUpdateCommand(loggingLogshuttleCmdRoot.CmdClause, data) + loggingNewRelicCmdRoot := newrelic.NewRootCommand(loggingCmdRoot.CmdClause, data) + loggingNewRelicCreate := newrelic.NewCreateCommand(loggingNewRelicCmdRoot.CmdClause, data) + loggingNewRelicDelete := newrelic.NewDeleteCommand(loggingNewRelicCmdRoot.CmdClause, data) + loggingNewRelicDescribe := newrelic.NewDescribeCommand(loggingNewRelicCmdRoot.CmdClause, data) + loggingNewRelicList := newrelic.NewListCommand(loggingNewRelicCmdRoot.CmdClause, data) + loggingNewRelicUpdate := newrelic.NewUpdateCommand(loggingNewRelicCmdRoot.CmdClause, data) + loggingNewRelicOTLPCmdRoot := newrelicotlp.NewRootCommand(loggingCmdRoot.CmdClause, data) + loggingNewRelicOTLPCreate := newrelicotlp.NewCreateCommand(loggingNewRelicOTLPCmdRoot.CmdClause, data) + loggingNewRelicOTLPDelete := newrelicotlp.NewDeleteCommand(loggingNewRelicOTLPCmdRoot.CmdClause, data) + loggingNewRelicOTLPDescribe := newrelicotlp.NewDescribeCommand(loggingNewRelicOTLPCmdRoot.CmdClause, data) + loggingNewRelicOTLPList := newrelicotlp.NewListCommand(loggingNewRelicOTLPCmdRoot.CmdClause, data) + loggingNewRelicOTLPUpdate := newrelicotlp.NewUpdateCommand(loggingNewRelicOTLPCmdRoot.CmdClause, data) + loggingOpenstackCmdRoot := openstack.NewRootCommand(loggingCmdRoot.CmdClause, data) + loggingOpenstackCreate := openstack.NewCreateCommand(loggingOpenstackCmdRoot.CmdClause, data) + loggingOpenstackDelete := openstack.NewDeleteCommand(loggingOpenstackCmdRoot.CmdClause, data) + loggingOpenstackDescribe := openstack.NewDescribeCommand(loggingOpenstackCmdRoot.CmdClause, data) + loggingOpenstackList := openstack.NewListCommand(loggingOpenstackCmdRoot.CmdClause, data) + loggingOpenstackUpdate := openstack.NewUpdateCommand(loggingOpenstackCmdRoot.CmdClause, data) + loggingPapertrailCmdRoot := papertrail.NewRootCommand(loggingCmdRoot.CmdClause, data) + loggingPapertrailCreate := papertrail.NewCreateCommand(loggingPapertrailCmdRoot.CmdClause, data) + loggingPapertrailDelete := papertrail.NewDeleteCommand(loggingPapertrailCmdRoot.CmdClause, data) + loggingPapertrailDescribe := papertrail.NewDescribeCommand(loggingPapertrailCmdRoot.CmdClause, data) + loggingPapertrailList := papertrail.NewListCommand(loggingPapertrailCmdRoot.CmdClause, data) + loggingPapertrailUpdate := papertrail.NewUpdateCommand(loggingPapertrailCmdRoot.CmdClause, data) + loggingS3CmdRoot := s3.NewRootCommand(loggingCmdRoot.CmdClause, data) + loggingS3Create := s3.NewCreateCommand(loggingS3CmdRoot.CmdClause, data) + loggingS3Delete := s3.NewDeleteCommand(loggingS3CmdRoot.CmdClause, data) + loggingS3Describe := s3.NewDescribeCommand(loggingS3CmdRoot.CmdClause, data) + loggingS3List := s3.NewListCommand(loggingS3CmdRoot.CmdClause, data) + loggingS3Update := s3.NewUpdateCommand(loggingS3CmdRoot.CmdClause, data) + loggingScalyrCmdRoot := scalyr.NewRootCommand(loggingCmdRoot.CmdClause, data) + loggingScalyrCreate := scalyr.NewCreateCommand(loggingScalyrCmdRoot.CmdClause, data) + loggingScalyrDelete := scalyr.NewDeleteCommand(loggingScalyrCmdRoot.CmdClause, data) + loggingScalyrDescribe := scalyr.NewDescribeCommand(loggingScalyrCmdRoot.CmdClause, data) + loggingScalyrList := scalyr.NewListCommand(loggingScalyrCmdRoot.CmdClause, data) + loggingScalyrUpdate := scalyr.NewUpdateCommand(loggingScalyrCmdRoot.CmdClause, data) + loggingSftpCmdRoot := sftp.NewRootCommand(loggingCmdRoot.CmdClause, data) + loggingSftpCreate := sftp.NewCreateCommand(loggingSftpCmdRoot.CmdClause, data) + loggingSftpDelete := sftp.NewDeleteCommand(loggingSftpCmdRoot.CmdClause, data) + loggingSftpDescribe := sftp.NewDescribeCommand(loggingSftpCmdRoot.CmdClause, data) + loggingSftpList := sftp.NewListCommand(loggingSftpCmdRoot.CmdClause, data) + loggingSftpUpdate := sftp.NewUpdateCommand(loggingSftpCmdRoot.CmdClause, data) + loggingSplunkCmdRoot := splunk.NewRootCommand(loggingCmdRoot.CmdClause, data) + loggingSplunkCreate := splunk.NewCreateCommand(loggingSplunkCmdRoot.CmdClause, data) + loggingSplunkDelete := splunk.NewDeleteCommand(loggingSplunkCmdRoot.CmdClause, data) + loggingSplunkDescribe := splunk.NewDescribeCommand(loggingSplunkCmdRoot.CmdClause, data) + loggingSplunkList := splunk.NewListCommand(loggingSplunkCmdRoot.CmdClause, data) + loggingSplunkUpdate := splunk.NewUpdateCommand(loggingSplunkCmdRoot.CmdClause, data) + loggingSumologicCmdRoot := sumologic.NewRootCommand(loggingCmdRoot.CmdClause, data) + loggingSumologicCreate := sumologic.NewCreateCommand(loggingSumologicCmdRoot.CmdClause, data) + loggingSumologicDelete := sumologic.NewDeleteCommand(loggingSumologicCmdRoot.CmdClause, data) + loggingSumologicDescribe := sumologic.NewDescribeCommand(loggingSumologicCmdRoot.CmdClause, data) + loggingSumologicList := sumologic.NewListCommand(loggingSumologicCmdRoot.CmdClause, data) + loggingSumologicUpdate := sumologic.NewUpdateCommand(loggingSumologicCmdRoot.CmdClause, data) + loggingSyslogCmdRoot := syslog.NewRootCommand(loggingCmdRoot.CmdClause, data) + loggingSyslogCreate := syslog.NewCreateCommand(loggingSyslogCmdRoot.CmdClause, data) + loggingSyslogDelete := syslog.NewDeleteCommand(loggingSyslogCmdRoot.CmdClause, data) + loggingSyslogDescribe := syslog.NewDescribeCommand(loggingSyslogCmdRoot.CmdClause, data) + loggingSyslogList := syslog.NewListCommand(loggingSyslogCmdRoot.CmdClause, data) + loggingSyslogUpdate := syslog.NewUpdateCommand(loggingSyslogCmdRoot.CmdClause, data) + objectStorageRoot := objectstorage.NewRootCommand(app, data) + objectStorageAccesskeysRoot := accesskeys.NewRootCommand(objectStorageRoot.CmdClause, data) + objectStorageAccesskeysCreate := accesskeys.NewCreateCommand(objectStorageAccesskeysRoot.CmdClause, data) + objectStorageAccesskeysDelete := accesskeys.NewDeleteCommand(objectStorageAccesskeysRoot.CmdClause, data) + objectStorageAccesskeysGet := accesskeys.NewGetCommand(objectStorageAccesskeysRoot.CmdClause, data) + objectStorageAccesskeysList := accesskeys.NewListCommand(objectStorageAccesskeysRoot.CmdClause, data) + popCmdRoot := pop.NewRootCommand(app, data) + productsCmdRoot := products.NewRootCommand(app, data) + profileCmdRoot := profile.NewRootCommand(app, data) + profileCreate := profile.NewCreateCommand(profileCmdRoot.CmdClause, data, ssoCmdRoot) + profileDelete := profile.NewDeleteCommand(profileCmdRoot.CmdClause, data) + profileList := profile.NewListCommand(profileCmdRoot.CmdClause, data) + profileSwitch := profile.NewSwitchCommand(profileCmdRoot.CmdClause, data, ssoCmdRoot) + profileToken := profile.NewTokenCommand(profileCmdRoot.CmdClause, data) + profileUpdate := profile.NewUpdateCommand(profileCmdRoot.CmdClause, data, ssoCmdRoot) + purgeCmdRoot := purge.NewRootCommand(app, data) + rateLimitCmdRoot := ratelimit.NewRootCommand(app, data) + rateLimitCreate := ratelimit.NewCreateCommand(rateLimitCmdRoot.CmdClause, data) + rateLimitDelete := ratelimit.NewDeleteCommand(rateLimitCmdRoot.CmdClause, data) + rateLimitDescribe := ratelimit.NewDescribeCommand(rateLimitCmdRoot.CmdClause, data) + rateLimitList := ratelimit.NewListCommand(rateLimitCmdRoot.CmdClause, data) + rateLimitUpdate := ratelimit.NewUpdateCommand(rateLimitCmdRoot.CmdClause, data) + resourcelinkCmdRoot := resourcelink.NewRootCommand(app, data) + resourcelinkCreate := resourcelink.NewCreateCommand(resourcelinkCmdRoot.CmdClause, data) + resourcelinkDelete := resourcelink.NewDeleteCommand(resourcelinkCmdRoot.CmdClause, data) + resourcelinkDescribe := resourcelink.NewDescribeCommand(resourcelinkCmdRoot.CmdClause, data) + resourcelinkList := resourcelink.NewListCommand(resourcelinkCmdRoot.CmdClause, data) + resourcelinkUpdate := resourcelink.NewUpdateCommand(resourcelinkCmdRoot.CmdClause, data) + secretstoreCmdRoot := secretstore.NewRootCommand(app, data) + secretstoreCreate := secretstore.NewCreateCommand(secretstoreCmdRoot.CmdClause, data) + secretstoreDescribe := secretstore.NewDescribeCommand(secretstoreCmdRoot.CmdClause, data) + secretstoreDelete := secretstore.NewDeleteCommand(secretstoreCmdRoot.CmdClause, data) + secretstoreList := secretstore.NewListCommand(secretstoreCmdRoot.CmdClause, data) + secretstoreentryCmdRoot := secretstoreentry.NewRootCommand(app, data) + secretstoreentryCreate := secretstoreentry.NewCreateCommand(secretstoreentryCmdRoot.CmdClause, data) + secretstoreentryDescribe := secretstoreentry.NewDescribeCommand(secretstoreentryCmdRoot.CmdClause, data) + secretstoreentryDelete := secretstoreentry.NewDeleteCommand(secretstoreentryCmdRoot.CmdClause, data) + secretstoreentryList := secretstoreentry.NewListCommand(secretstoreentryCmdRoot.CmdClause, data) + serviceCmdRoot := service.NewRootCommand(app, data) + serviceCreate := service.NewCreateCommand(serviceCmdRoot.CmdClause, data) + serviceDelete := service.NewDeleteCommand(serviceCmdRoot.CmdClause, data) + serviceDescribe := service.NewDescribeCommand(serviceCmdRoot.CmdClause, data) + serviceList := service.NewListCommand(serviceCmdRoot.CmdClause, data) + serviceSearch := service.NewSearchCommand(serviceCmdRoot.CmdClause, data) + serviceUpdate := service.NewUpdateCommand(serviceCmdRoot.CmdClause, data) + serviceauthCmdRoot := serviceauth.NewRootCommand(app, data) + serviceauthCreate := serviceauth.NewCreateCommand(serviceauthCmdRoot.CmdClause, data) + serviceauthDelete := serviceauth.NewDeleteCommand(serviceauthCmdRoot.CmdClause, data) + serviceauthDescribe := serviceauth.NewDescribeCommand(serviceauthCmdRoot.CmdClause, data) + serviceauthList := serviceauth.NewListCommand(serviceauthCmdRoot.CmdClause, data) + serviceauthUpdate := serviceauth.NewUpdateCommand(serviceauthCmdRoot.CmdClause, data) + serviceVersionCmdRoot := serviceversion.NewRootCommand(app, data) + serviceVersionActivate := serviceversion.NewActivateCommand(serviceVersionCmdRoot.CmdClause, data) + serviceVersionClone := serviceversion.NewCloneCommand(serviceVersionCmdRoot.CmdClause, data) + serviceVersionDeactivate := serviceversion.NewDeactivateCommand(serviceVersionCmdRoot.CmdClause, data) + serviceVersionList := serviceversion.NewListCommand(serviceVersionCmdRoot.CmdClause, data) + serviceVersionLock := serviceversion.NewLockCommand(serviceVersionCmdRoot.CmdClause, data) + serviceVersionStage := serviceversion.NewStageCommand(serviceVersionCmdRoot.CmdClause, data) + serviceVersionUnstage := serviceversion.NewUnstageCommand(serviceVersionCmdRoot.CmdClause, data) + serviceVersionUpdate := serviceversion.NewUpdateCommand(serviceVersionCmdRoot.CmdClause, data) + statsCmdRoot := stats.NewRootCommand(app, data) + statsHistorical := stats.NewHistoricalCommand(statsCmdRoot.CmdClause, data) + statsRealtime := stats.NewRealtimeCommand(statsCmdRoot.CmdClause, data) + statsRegions := stats.NewRegionsCommand(statsCmdRoot.CmdClause, data) + tlsConfigCmdRoot := tlsconfig.NewRootCommand(app, data) + tlsConfigDescribe := tlsconfig.NewDescribeCommand(tlsConfigCmdRoot.CmdClause, data) + tlsConfigList := tlsconfig.NewListCommand(tlsConfigCmdRoot.CmdClause, data) + tlsConfigUpdate := tlsconfig.NewUpdateCommand(tlsConfigCmdRoot.CmdClause, data) + tlsCustomCmdRoot := tlscustom.NewRootCommand(app, data) + tlsCustomActivationCmdRoot := tlscustomactivation.NewRootCommand(tlsCustomCmdRoot.CmdClause, data) + tlsCustomActivationCreate := tlscustomactivation.NewCreateCommand(tlsCustomActivationCmdRoot.CmdClause, data) + tlsCustomActivationDelete := tlscustomactivation.NewDeleteCommand(tlsCustomActivationCmdRoot.CmdClause, data) + tlsCustomActivationDescribe := tlscustomactivation.NewDescribeCommand(tlsCustomActivationCmdRoot.CmdClause, data) + tlsCustomActivationList := tlscustomactivation.NewListCommand(tlsCustomActivationCmdRoot.CmdClause, data) + tlsCustomActivationUpdate := tlscustomactivation.NewUpdateCommand(tlsCustomActivationCmdRoot.CmdClause, data) + tlsCustomCertificateCmdRoot := tlscustomcertificate.NewRootCommand(tlsCustomCmdRoot.CmdClause, data) + tlsCustomCertificateCreate := tlscustomcertificate.NewCreateCommand(tlsCustomCertificateCmdRoot.CmdClause, data) + tlsCustomCertificateDelete := tlscustomcertificate.NewDeleteCommand(tlsCustomCertificateCmdRoot.CmdClause, data) + tlsCustomCertificateDescribe := tlscustomcertificate.NewDescribeCommand(tlsCustomCertificateCmdRoot.CmdClause, data) + tlsCustomCertificateList := tlscustomcertificate.NewListCommand(tlsCustomCertificateCmdRoot.CmdClause, data) + tlsCustomCertificateUpdate := tlscustomcertificate.NewUpdateCommand(tlsCustomCertificateCmdRoot.CmdClause, data) + tlsCustomDomainCmdRoot := tlscustomdomain.NewRootCommand(tlsCustomCmdRoot.CmdClause, data) + tlsCustomDomainList := tlscustomdomain.NewListCommand(tlsCustomDomainCmdRoot.CmdClause, data) + tlsCustomPrivateKeyCmdRoot := tlscustomprivatekey.NewRootCommand(tlsCustomCmdRoot.CmdClause, data) + tlsCustomPrivateKeyCreate := tlscustomprivatekey.NewCreateCommand(tlsCustomPrivateKeyCmdRoot.CmdClause, data) + tlsCustomPrivateKeyDelete := tlscustomprivatekey.NewDeleteCommand(tlsCustomPrivateKeyCmdRoot.CmdClause, data) + tlsCustomPrivateKeyDescribe := tlscustomprivatekey.NewDescribeCommand(tlsCustomPrivateKeyCmdRoot.CmdClause, data) + tlsCustomPrivateKeyList := tlscustomprivatekey.NewListCommand(tlsCustomPrivateKeyCmdRoot.CmdClause, data) + tlsPlatformCmdRoot := tlsplatform.NewRootCommand(app, data) + tlsPlatformCreate := tlsplatform.NewCreateCommand(tlsPlatformCmdRoot.CmdClause, data) + tlsPlatformDelete := tlsplatform.NewDeleteCommand(tlsPlatformCmdRoot.CmdClause, data) + tlsPlatformDescribe := tlsplatform.NewDescribeCommand(tlsPlatformCmdRoot.CmdClause, data) + tlsPlatformList := tlsplatform.NewListCommand(tlsPlatformCmdRoot.CmdClause, data) + tlsPlatformUpdate := tlsplatform.NewUpdateCommand(tlsPlatformCmdRoot.CmdClause, data) + tlsSubscriptionCmdRoot := tlssubscription.NewRootCommand(app, data) + tlsSubscriptionCreate := tlssubscription.NewCreateCommand(tlsSubscriptionCmdRoot.CmdClause, data) + tlsSubscriptionDelete := tlssubscription.NewDeleteCommand(tlsSubscriptionCmdRoot.CmdClause, data) + tlsSubscriptionDescribe := tlssubscription.NewDescribeCommand(tlsSubscriptionCmdRoot.CmdClause, data) + tlsSubscriptionList := tlssubscription.NewListCommand(tlsSubscriptionCmdRoot.CmdClause, data) + tlsSubscriptionUpdate := tlssubscription.NewUpdateCommand(tlsSubscriptionCmdRoot.CmdClause, data) + updateRoot := update.NewRootCommand(app, data) + userCmdRoot := user.NewRootCommand(app, data) + userCreate := user.NewCreateCommand(userCmdRoot.CmdClause, data) + userDelete := user.NewDeleteCommand(userCmdRoot.CmdClause, data) + userDescribe := user.NewDescribeCommand(userCmdRoot.CmdClause, data) + userList := user.NewListCommand(userCmdRoot.CmdClause, data) + userUpdate := user.NewUpdateCommand(userCmdRoot.CmdClause, data) + vclCmdRoot := vcl.NewRootCommand(app, data) + vclConditionCmdRoot := condition.NewRootCommand(vclCmdRoot.CmdClause, data) + vclConditionCreate := condition.NewCreateCommand(vclConditionCmdRoot.CmdClause, data) + vclConditionDelete := condition.NewDeleteCommand(vclConditionCmdRoot.CmdClause, data) + vclConditionDescribe := condition.NewDescribeCommand(vclConditionCmdRoot.CmdClause, data) + vclConditionList := condition.NewListCommand(vclConditionCmdRoot.CmdClause, data) + vclConditionUpdate := condition.NewUpdateCommand(vclConditionCmdRoot.CmdClause, data) + vclCustomCmdRoot := custom.NewRootCommand(vclCmdRoot.CmdClause, data) + vclCustomCreate := custom.NewCreateCommand(vclCustomCmdRoot.CmdClause, data) + vclCustomDelete := custom.NewDeleteCommand(vclCustomCmdRoot.CmdClause, data) + vclCustomDescribe := custom.NewDescribeCommand(vclCustomCmdRoot.CmdClause, data) + vclCustomList := custom.NewListCommand(vclCustomCmdRoot.CmdClause, data) + vclCustomUpdate := custom.NewUpdateCommand(vclCustomCmdRoot.CmdClause, data) + vclSnippetCmdRoot := snippet.NewRootCommand(vclCmdRoot.CmdClause, data) + vclSnippetCreate := snippet.NewCreateCommand(vclSnippetCmdRoot.CmdClause, data) + vclSnippetDelete := snippet.NewDeleteCommand(vclSnippetCmdRoot.CmdClause, data) + vclSnippetDescribe := snippet.NewDescribeCommand(vclSnippetCmdRoot.CmdClause, data) + vclSnippetList := snippet.NewListCommand(vclSnippetCmdRoot.CmdClause, data) + vclSnippetUpdate := snippet.NewUpdateCommand(vclSnippetCmdRoot.CmdClause, data) + versionCmdRoot := version.NewRootCommand(app, data) + whoamiCmdRoot := whoami.NewRootCommand(app, data) + + return []argparser.Command{ + shellcompleteCmdRoot, + aclCmdRoot, + aclCreate, + aclDelete, + aclDescribe, + aclList, + aclUpdate, + aclEntryCmdRoot, + aclEntryCreate, + aclEntryDelete, + aclEntryDescribe, + aclEntryList, + aclEntryUpdate, + alertsCreate, + alertsDelete, + alertsDescribe, + alertsList, + alertsListHistory, + alertsUpdate, + authtokenCmdRoot, + authtokenCreate, + authtokenDelete, + authtokenDescribe, + authtokenList, + backendCmdRoot, + backendCreate, + backendDelete, + backendDescribe, + backendList, + backendUpdate, + computeCmdRoot, + computeACLCmdRoot, + computeACLCreate, + computeACLList, + computeACLDescribe, + computeACLDelete, + computeACLUpdate, + computeACLLookup, + computeACLEntriesList, + computeBuild, + computeDeploy, + computeHashFiles, + computeHashsum, + computeInit, + computeMetadata, + computePack, + computePublish, + computeServe, + computeUpdate, + computeValidate, + configCmdRoot, + configstoreCmdRoot, + configstoreCreate, + configstoreDelete, + configstoreDescribe, + configstoreList, + configstoreListServices, + configstoreUpdate, + configstoreentryCmdRoot, + configstoreentryCreate, + configstoreentryDelete, + configstoreentryDescribe, + configstoreentryList, + configstoreentryUpdate, + dashboardCmdRoot, + dashboardList, + dashboardCreate, + dashboardDescribe, + dashboardUpdate, + dashboardDelete, + dashboardItemCmdRoot, + dashboardItemCreate, + dashboardItemDescribe, + dashboardItemUpdate, + dashboardItemDelete, + dictionaryCmdRoot, + dictionaryCreate, + dictionaryDelete, + dictionaryDescribe, + dictionaryEntryCmdRoot, + dictionaryEntryCreate, + dictionaryEntryDelete, + dictionaryEntryDescribe, + dictionaryEntryList, + dictionaryEntryUpdate, + dictionaryList, + dictionaryUpdate, + domainCmdRoot, + domainCreate, + domainDelete, + domainDescribe, + domainList, + domainUpdate, + domainValidate, + domainv1CmdRoot, + domainv1Create, + domainv1Delete, + domainv1Describe, + domainv1List, + domainv1Update, + healthcheckCmdRoot, + healthcheckCreate, + healthcheckDelete, + healthcheckDescribe, + healthcheckList, + healthcheckUpdate, + installRoot, + ipCmdRoot, + kvstoreCreate, + kvstoreDelete, + kvstoreDescribe, + kvstoreList, + kvstoreentryCreate, + kvstoreentryDelete, + kvstoreentryDescribe, + kvstoreentryList, + logtailCmdRoot, + loggingAzureblobCmdRoot, + loggingAzureblobCreate, + loggingAzureblobDelete, + loggingAzureblobDescribe, + loggingAzureblobList, + loggingAzureblobUpdate, + loggingBigQueryCmdRoot, + loggingBigQueryCreate, + loggingBigQueryDelete, + loggingBigQueryDescribe, + loggingBigQueryList, + loggingBigQueryUpdate, + loggingCloudfilesCmdRoot, + loggingCloudfilesCreate, + loggingCloudfilesDelete, + loggingCloudfilesDescribe, + loggingCloudfilesList, + loggingCloudfilesUpdate, + loggingCmdRoot, + loggingDatadogCmdRoot, + loggingDatadogCreate, + loggingDatadogDelete, + loggingDatadogDescribe, + loggingDatadogList, + loggingDatadogUpdate, + loggingDigitaloceanCmdRoot, + loggingDigitaloceanCreate, + loggingDigitaloceanDelete, + loggingDigitaloceanDescribe, + loggingDigitaloceanList, + loggingDigitaloceanUpdate, + loggingElasticsearchCmdRoot, + loggingElasticsearchCreate, + loggingElasticsearchDelete, + loggingElasticsearchDescribe, + loggingElasticsearchList, + loggingElasticsearchUpdate, + loggingFtpCmdRoot, + loggingFtpCreate, + loggingFtpDelete, + loggingFtpDescribe, + loggingFtpList, + loggingFtpUpdate, + loggingGcsCmdRoot, + loggingGcsCreate, + loggingGcsDelete, + loggingGcsDescribe, + loggingGcsList, + loggingGcsUpdate, + loggingGooglepubsubCmdRoot, + loggingGooglepubsubCreate, + loggingGooglepubsubDelete, + loggingGooglepubsubDescribe, + loggingGooglepubsubList, + loggingGooglepubsubUpdate, + loggingGrafanacloudlogsCmdRoot, + loggingGrafanacloudlogsCreate, + loggingGrafanacloudlogsDelete, + loggingGrafanacloudlogsDescribe, + loggingGrafanacloudlogsList, + loggingGrafanacloudlogsUpdate, + loggingHerokuCmdRoot, + loggingHerokuCreate, + loggingHerokuDelete, + loggingHerokuDescribe, + loggingHerokuList, + loggingHerokuUpdate, + loggingHoneycombCmdRoot, + loggingHoneycombCreate, + loggingHoneycombDelete, + loggingHoneycombDescribe, + loggingHoneycombList, + loggingHoneycombUpdate, + loggingHTTPSCmdRoot, + loggingHTTPSCreate, + loggingHTTPSDelete, + loggingHTTPSDescribe, + loggingHTTPSList, + loggingHTTPSUpdate, + loggingKafkaCmdRoot, + loggingKafkaCreate, + loggingKafkaDelete, + loggingKafkaDescribe, + loggingKafkaList, + loggingKafkaUpdate, + loggingKinesisCmdRoot, + loggingKinesisCreate, + loggingKinesisDelete, + loggingKinesisDescribe, + loggingKinesisList, + loggingKinesisUpdate, + loggingLogglyCmdRoot, + loggingLogglyCreate, + loggingLogglyDelete, + loggingLogglyDescribe, + loggingLogglyList, + loggingLogglyUpdate, + loggingLogshuttleCmdRoot, + loggingLogshuttleCreate, + loggingLogshuttleDelete, + loggingLogshuttleDescribe, + loggingLogshuttleList, + loggingLogshuttleUpdate, + loggingNewRelicCmdRoot, + loggingNewRelicCreate, + loggingNewRelicDelete, + loggingNewRelicDescribe, + loggingNewRelicList, + loggingNewRelicUpdate, + loggingNewRelicOTLPCmdRoot, + loggingNewRelicOTLPCreate, + loggingNewRelicOTLPDelete, + loggingNewRelicOTLPDescribe, + loggingNewRelicOTLPList, + loggingNewRelicOTLPUpdate, + loggingOpenstackCmdRoot, + loggingOpenstackCreate, + loggingOpenstackDelete, + loggingOpenstackDescribe, + loggingOpenstackList, + loggingOpenstackUpdate, + loggingPapertrailCmdRoot, + loggingPapertrailCreate, + loggingPapertrailDelete, + loggingPapertrailDescribe, + loggingPapertrailList, + loggingPapertrailUpdate, + loggingS3CmdRoot, + loggingS3Create, + loggingS3Delete, + loggingS3Describe, + loggingS3List, + loggingS3Update, + loggingScalyrCmdRoot, + loggingScalyrCreate, + loggingScalyrDelete, + loggingScalyrDescribe, + loggingScalyrList, + loggingScalyrUpdate, + loggingSftpCmdRoot, + loggingSftpCreate, + loggingSftpDelete, + loggingSftpDescribe, + loggingSftpList, + loggingSftpUpdate, + loggingSplunkCmdRoot, + loggingSplunkCreate, + loggingSplunkDelete, + loggingSplunkDescribe, + loggingSplunkList, + loggingSplunkUpdate, + loggingSumologicCmdRoot, + loggingSumologicCreate, + loggingSumologicDelete, + loggingSumologicDescribe, + loggingSumologicList, + loggingSumologicUpdate, + loggingSyslogCmdRoot, + loggingSyslogCreate, + loggingSyslogDelete, + loggingSyslogDescribe, + loggingSyslogList, + loggingSyslogUpdate, + objectStorageRoot, + objectStorageAccesskeysRoot, + objectStorageAccesskeysCreate, + objectStorageAccesskeysDelete, + objectStorageAccesskeysGet, + objectStorageAccesskeysList, + popCmdRoot, + productsCmdRoot, + profileCmdRoot, + profileCreate, + profileDelete, + profileList, + profileSwitch, + profileToken, + profileUpdate, + purgeCmdRoot, + rateLimitCmdRoot, + rateLimitCreate, + rateLimitDelete, + rateLimitDescribe, + rateLimitList, + rateLimitUpdate, + resourcelinkCmdRoot, + resourcelinkCreate, + resourcelinkDelete, + resourcelinkDescribe, + resourcelinkList, + resourcelinkUpdate, + secretstoreCreate, + secretstoreDescribe, + secretstoreDelete, + secretstoreList, + secretstoreentryCreate, + secretstoreentryDescribe, + secretstoreentryDelete, + secretstoreentryList, + serviceCmdRoot, + serviceCreate, + serviceDelete, + serviceDescribe, + serviceList, + serviceSearch, + serviceUpdate, + serviceauthCmdRoot, + serviceauthCreate, + serviceauthDelete, + serviceauthDescribe, + serviceauthList, + serviceauthUpdate, + serviceVersionActivate, + serviceVersionClone, + serviceVersionCmdRoot, + serviceVersionDeactivate, + serviceVersionList, + serviceVersionLock, + serviceVersionStage, + serviceVersionUnstage, + serviceVersionUpdate, + ssoCmdRoot, + statsCmdRoot, + statsHistorical, + statsRealtime, + statsRegions, + tlsConfigCmdRoot, + tlsConfigDescribe, + tlsConfigList, + tlsConfigUpdate, + tlsCustomCmdRoot, + tlsCustomActivationCmdRoot, + tlsCustomActivationCreate, + tlsCustomActivationDelete, + tlsCustomActivationDescribe, + tlsCustomActivationList, + tlsCustomActivationUpdate, + tlsCustomCertificateCmdRoot, + tlsCustomCertificateCreate, + tlsCustomCertificateDelete, + tlsCustomCertificateDescribe, + tlsCustomCertificateList, + tlsCustomCertificateUpdate, + tlsCustomDomainCmdRoot, + tlsCustomDomainList, + tlsCustomPrivateKeyCmdRoot, + tlsCustomPrivateKeyCreate, + tlsCustomPrivateKeyDelete, + tlsCustomPrivateKeyDescribe, + tlsCustomPrivateKeyList, + tlsPlatformCmdRoot, + tlsPlatformCreate, + tlsPlatformDelete, + tlsPlatformDescribe, + tlsPlatformList, + tlsPlatformUpdate, + tlsSubscriptionCmdRoot, + tlsSubscriptionCreate, + tlsSubscriptionDelete, + tlsSubscriptionDescribe, + tlsSubscriptionList, + tlsSubscriptionUpdate, + updateRoot, + userCmdRoot, + userCreate, + userDelete, + userDescribe, + userList, + userUpdate, + vclCmdRoot, + vclConditionCmdRoot, + vclConditionCreate, + vclConditionDelete, + vclConditionDescribe, + vclConditionList, + vclConditionUpdate, + vclCustomCmdRoot, + vclCustomCreate, + vclCustomDelete, + vclCustomDescribe, + vclCustomList, + vclCustomUpdate, + vclSnippetCmdRoot, + vclSnippetCreate, + vclSnippetDelete, + vclSnippetDescribe, + vclSnippetList, + vclSnippetUpdate, + versionCmdRoot, + whoamiCmdRoot, + } +} diff --git a/pkg/commands/compute/build.go b/pkg/commands/compute/build.go new file mode 100644 index 000000000..41bb87312 --- /dev/null +++ b/pkg/commands/compute/build.go @@ -0,0 +1,939 @@ +package compute + +import ( + "bufio" + "crypto/rand" + "encoding/json" + "errors" + "fmt" + "io" + "math" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "strings" + "time" + + "github.com/kennygrant/sanitize" + "github.com/mholt/archiver/v3" + "golang.org/x/text/cases" + textlang "golang.org/x/text/language" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/check" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/filesystem" + "github.com/fastly/cli/pkg/github" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/revision" + "github.com/fastly/cli/pkg/text" +) + +// IgnoreFilePath is the filepath name of the Fastly ignore file. +const IgnoreFilePath = ".fastlyignore" + +// CustomPostScriptMessage is the message displayed to a user when there is +// either a post_init or post_build script defined. +const CustomPostScriptMessage = "This project has a custom post_%s script defined in the %s manifest" + +// ErrWasmtoolsNotFound represents an error finding the binary installed. +var ErrWasmtoolsNotFound = fsterr.RemediationError{ + Inner: fmt.Errorf("wasm-tools not found"), + Remediation: fsterr.BugRemediation, +} + +// Flags represents the flags defined for the command. +type Flags struct { + Dir string + Env string + IncludeSrc bool + Lang string + PackageName string + Timeout int +} + +// BuildCommand produces a deployable artifact from files on the local disk. +type BuildCommand struct { + argparser.Base + + // NOTE: Composite commands require these build flags to be public. + // e.g. serve, publish, hashsum, hash-files + // This is so they can set values appropriately before calling Build.Exec(). + Flags Flags + MetadataDisable bool + MetadataFilterEnvVars string + MetadataShow bool + SkipChangeDir bool // set by parent composite commands (e.g. serve, publish) +} + +// NewBuildCommand returns a usable command registered under the parent. +func NewBuildCommand(parent argparser.Registerer, g *global.Data) *BuildCommand { + var c BuildCommand + c.Globals = g + c.CmdClause = parent.Command("build", "Build a Compute package locally") + + // NOTE: when updating these flags, be sure to update the composite commands: + // `compute publish` and `compute serve`. + c.CmdClause.Flag("dir", "Project directory to build (default: current directory)").Short('C').StringVar(&c.Flags.Dir) + c.CmdClause.Flag("env", "The manifest environment config to use (e.g. 'stage' will attempt to read 'fastly.stage.toml')").StringVar(&c.Flags.Env) + c.CmdClause.Flag("include-source", "Include source code in built package").BoolVar(&c.Flags.IncludeSrc) + c.CmdClause.Flag("language", "Language type").StringVar(&c.Flags.Lang) + c.CmdClause.Flag("metadata-disable", "Disable Wasm binary metadata annotations").BoolVar(&c.MetadataDisable) + c.CmdClause.Flag("metadata-filter-envvars", "Redact specified environment variables from [scripts.env_vars] using comma-separated list").StringVar(&c.MetadataFilterEnvVars) + c.CmdClause.Flag("metadata-show", "Inspect the Wasm binary metadata").BoolVar(&c.MetadataShow) + c.CmdClause.Flag("package-name", "Package name").StringVar(&c.Flags.PackageName) + c.CmdClause.Flag("timeout", "Timeout, in seconds, for the build compilation step").IntVar(&c.Flags.Timeout) + + return &c +} + +// Exec implements the command interface. +func (c *BuildCommand) Exec(in io.Reader, out io.Writer) (err error) { + // We'll restore this at the end to print a final successful build output. + originalOut := out + if c.Globals.Flags.Quiet { + out = io.Discard + } + + manifestFilename := EnvironmentManifest(c.Flags.Env) + if c.Flags.Env != "" { + if c.Globals.Verbose() { + text.Info(out, EnvManifestMsg, manifestFilename, manifest.Filename) + } + } + wd, err := os.Getwd() + if err != nil { + return fmt.Errorf("failed to get current working directory: %w", err) + } + defer func() { + _ = os.Chdir(wd) + }() + manifestPath := filepath.Join(wd, manifestFilename) + + var projectDir string + if !c.SkipChangeDir { + projectDir, err = ChangeProjectDirectory(c.Flags.Dir) + if err != nil { + return err + } + if projectDir != "" { + if c.Globals.Verbose() { + text.Info(out, ProjectDirMsg, projectDir) + } + manifestPath = filepath.Join(projectDir, manifestFilename) + } + } + + spinner, err := text.NewSpinner(out) + if err != nil { + return err + } + + defer func(errLog fsterr.LogInterface) { + if err != nil { + errLog.Add(err) + } + }(c.Globals.ErrLog) + + if c.Globals.Verbose() { + text.Break(out) + } + err = spinner.Process(fmt.Sprintf("Verifying %s", manifestFilename), func(_ *text.SpinnerWrapper) error { + // The check for c.SkipChangeDir here is because we might need to attempt + // another read of the manifest file. To explain: if we're skipping the + // change of directory, it means we were called from a composite command, + // which has already changed directory to one that contains the fastly.toml + // file. This means we should try reading the manifest file from the new + // location as the potential ReadError() would have been based on the + // initial directory the CLI was invoked from. + if c.SkipChangeDir || projectDir != "" || c.Flags.Env != "" { + err = c.Globals.Manifest.File.Read(manifestPath) + } else { + err = c.Globals.Manifest.File.ReadError() + } + if err != nil { + if errors.Is(err, os.ErrNotExist) { + err = fsterr.ErrReadingManifest + } + c.Globals.ErrLog.Add(err) + return err + } + return nil + }) + if err != nil { + return err + } + + wasmtools, wasmtoolsErr := GetWasmTools(spinner, out, c.Globals.Versioners.WasmTools, c.Globals) + + var pkgName string + err = spinner.Process("Identifying package name", func(_ *text.SpinnerWrapper) error { + pkgName, err = c.PackageName(manifestFilename) + return err + }) + if err != nil { + return err + } + + var toolchain string + err = spinner.Process("Identifying toolchain", func(_ *text.SpinnerWrapper) error { + toolchain, err = identifyToolchain(c) + return err + }) + if err != nil { + return err + } + + language, err := language(toolchain, manifestFilename, c, in, out, spinner) + if err != nil { + return err + } + + err = binDir(c) + if err != nil { + return err + } + + if err := language.Build(); err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Language": language.Name, + }) + return err + } + + // IMPORTANT: We ignore errors downloading wasm-tools. + // This is because we don't want to block a user from building their project. + // Annotating the compiled binary with metadata isn't that important. + if wasmtoolsErr == nil { + metadataProcessedBy := fmt.Sprintf( + "--processed-by=fastly=%s (%s)", + revision.AppVersion, cases.Title(textlang.English).String(language.Name), + ) + metadataArgs := []string{ + "metadata", "add", binWasmPath, metadataProcessedBy, + } + + metadataDisable, _ := strconv.ParseBool(c.Globals.Env.WasmMetadataDisable) + if !c.MetadataDisable && !metadataDisable { + if err := c.AnnotateWasmBinaryLong(wasmtools, metadataArgs, language); err != nil { + return err + } + } else { + if err := c.AnnotateWasmBinaryShort(wasmtools, metadataArgs); err != nil { + return err + } + } + if c.MetadataShow { + c.ShowMetadata(wasmtools, out) + } + } else { + if !c.Globals.Verbose() { + text.Break(out) + } + text.Info(out, "There was an error downloading the wasm-tools (used for binary annotations) but we don't let that block you building your project. For reference here is the error (in case you want to let us know about it): %s\n\n", wasmtoolsErr.Error()) + } + + dest := filepath.Join("pkg", fmt.Sprintf("%s.tar.gz", pkgName)) + err = spinner.Process("Creating package archive", func(_ *text.SpinnerWrapper) error { + // IMPORTANT: The minimum package requirement is `fastly.toml` and `main.wasm`. + // + // The Fastly platform will reject a package that doesn't have a manifest + // named exactly fastly.toml which means if the user is building and + // deploying a package with an environment manifest (e.g. fastly.stage.toml) + // then we need to: + // + // 1. Rename any existing fastly.toml to fastly.toml.backup. + // 2. Make a temp copy of the environment manifest and name it fastly.toml + // 3. Remove the newly created fastly.toml once the packaging is done + // 4. Rename the fastly.toml.backup back to fastly.toml + if c.Flags.Env != "" { + // 1. Rename any existing fastly.toml to fastly.toml.backup. + // + // For example, the user is trying to deploy a fastly.stage.toml rather + // than the standard fastly.toml manifest. + if _, err := os.Stat(manifest.Filename); err == nil { + backup := fmt.Sprintf("%s.backup.%d", manifest.Filename, time.Now().Unix()) + if err := os.Rename(manifest.Filename, backup); err != nil { + return fmt.Errorf("failed to backup primary manifest file: %w", err) + } + defer func() { + // 4. Rename the fastly.toml.backup back to fastly.toml + if err = os.Rename(backup, manifest.Filename); err != nil { + text.Error(out, err.Error()) + } + }() + } else { + // 3. Remove the newly created fastly.toml once the packaging is done + // + // If there wasn't an existing fastly.toml because the user only wants + // to work with environment manifests (e.g. fastly.stage.toml and + // fastly.production.toml) then we should remove the fastly.toml that we + // created just for the packaging process (see step 2. below). + defer func() { + if err = os.Remove(manifest.Filename); err != nil { + text.Error(out, err.Error()) + } + }() + } + // 2. Make a temp copy of the environment manifest and name it fastly.toml + // + // If there was no existing fastly.toml then this step will create one, so + // we need to make sure we remove it after packaging has finished so as to + // not confuse the user with a fastly.toml that has suddenly appeared (see + // step 3. above). + if err := filesystem.CopyFile(manifestFilename, manifest.Filename); err != nil { + return fmt.Errorf("failed to copy environment manifest file: %w", err) + } + } + + files := []string{ + manifest.Filename, + binWasmPath, + } + files, err = c.includeSourceCode(files, language.SourceDirectory) + if err != nil { + return err + } + err = CreatePackageArchive(files, dest) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Files": files, + "Destination": dest, + }) + return fmt.Errorf("error creating package archive: %w", err) + } + return nil + }) + if err != nil { + return err + } + + out = originalOut + text.Success(out, "\nBuilt package (%s)", dest) + return nil +} + +// AnnotateWasmBinaryShort annotates the Wasm binary with only the CLI version. +func (c *BuildCommand) AnnotateWasmBinaryShort(wasmtools string, args []string) error { + return c.Globals.ExecuteWasmTools(wasmtools, args, c.Globals) +} + +// AnnotateWasmBinaryLong annotates the Wasm binary will all available data. +func (c *BuildCommand) AnnotateWasmBinaryLong(wasmtools string, args []string, language *Language) error { + var ms runtime.MemStats + runtime.ReadMemStats(&ms) + + // Allow customer to specify their own env variables to be filtered. + ExtendStaticSecretEnvVars(c.MetadataFilterEnvVars) + + dc := DataCollection{} + + metadata := c.Globals.Config.WasmMetadata + + // Only record basic data if user has disabled all other metadata collection. + if metadata.BuildInfo == "disable" && metadata.MachineInfo == "disable" && metadata.PackageInfo == "disable" && metadata.ScriptInfo == "disable" { + return c.AnnotateWasmBinaryShort(wasmtools, args) + } + + if metadata.BuildInfo == "enable" { + dc.BuildInfo = DataCollectionBuildInfo{ + MemoryHeapAlloc: bucketMB(bytesToMB(ms.HeapAlloc)) + "MB", + } + } + if metadata.MachineInfo == "enable" { + dc.MachineInfo = DataCollectionMachineInfo{ + Arch: runtime.GOARCH, + CPUs: runtime.NumCPU(), + Compiler: runtime.Compiler, + GoVersion: runtime.Version(), + OS: runtime.GOOS, + } + } + if metadata.PackageInfo == "enable" { + dc.PackageInfo = DataCollectionPackageInfo{ + ClonedFrom: c.Globals.Manifest.File.ClonedFrom, + Packages: language.Dependencies(), + } + } + if metadata.ScriptInfo == "enable" { + dc.ScriptInfo = DataCollectionScriptInfo{ + DefaultBuildUsed: language.DefaultBuildScript(), + BuildScript: FilterSecretsFromString(c.Globals.Manifest.File.Scripts.Build), + EnvVars: FilterSecretsFromSlice(c.Globals.Manifest.File.Scripts.EnvVars), + PostInitScript: FilterSecretsFromString(c.Globals.Manifest.File.Scripts.PostInit), + PostBuildScript: FilterSecretsFromString(c.Globals.Manifest.File.Scripts.PostBuild), + } + } + + data, err := json.Marshal(dc) + if err != nil { + text.Info(c.Globals.Output, "failed to marshal DataCollection struct into JSON: %s", err) + } + args = append(args, fmt.Sprintf("--processed-by=fastly_data=%s", data)) + return c.Globals.ExecuteWasmTools(wasmtools, args, c.Globals) +} + +// ShowMetadata displays the metadata attached to the Wasm binary. +func (c *BuildCommand) ShowMetadata(wasmtools string, out io.Writer) { + // gosec flagged this: + // G204 (CWE-78): Subprocess launched with variable + // Disabling as the variables come from trusted sources. + // #nosec + // nosemgrep + command := exec.Command(wasmtools, "metadata", "show", binWasmPath) + wasmtoolsOutput, err := command.Output() + if err != nil { + text.Error(out, "failed to execute wasm-tools metadata command: %s\n\n", err) + return + } + text.Info(out, "\nBelow is the metadata attached to the Wasm binary\n\n") + fmt.Fprintln(out, string(wasmtoolsOutput)) + text.Break(out) +} + +// includeSourceCode calculates what source code files to include in the final +// package.tar.gz that is uploaded to the Fastly API. +// +// TODO: Investigate possible change to --include-source flag. +// The following implementation presumes source code is stored in a constant +// location, which might not be true for all users. We should look at whether +// we should change the --include-source flag to not be a boolean but to +// accept a 'source code' path instead. +func (c *BuildCommand) includeSourceCode(files []string, srcDir string) ([]string, error) { + empty := make([]string, 0) + + if c.Flags.IncludeSrc { + ignoreFiles, err := GetIgnoredFiles(IgnoreFilePath) + if err != nil { + c.Globals.ErrLog.Add(err) + return empty, err + } + + binFiles, err := GetNonIgnoredFiles("bin", ignoreFiles) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Ignore files": ignoreFiles, + }) + return empty, err + } + files = append(files, binFiles...) + + srcFiles, err := GetNonIgnoredFiles(srcDir, ignoreFiles) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Source directory": srcDir, + "Ignore files": ignoreFiles, + }) + return empty, err + } + files = append(files, srcFiles...) + } + + return files, nil +} + +// PackageName acquires the package name from either a flag or manifest. +// Additionally it will sanitize the name. +func (c *BuildCommand) PackageName(manifestFilename string) (string, error) { + var name string + + switch { + case c.Flags.PackageName != "": + name = c.Flags.PackageName + case c.Globals.Manifest.File.Name != "": + name = c.Globals.Manifest.File.Name // use the project name as a fallback + default: + return "", fsterr.RemediationError{ + Inner: fmt.Errorf("package name is missing"), + Remediation: fmt.Sprintf("Add a name to the %s 'name' field. Reference: https://www.fastly.com/documentation/reference/compute/fastly-toml", manifestFilename), + } + } + + return sanitize.BaseName(name), nil +} + +// ExecuteWasmTools calls the wasm-tools binary. +func ExecuteWasmTools(wasmtools string, args []string, d *global.Data) error { + errMsg := "failed to annotate binary with metadata: %s\n\n" + // gosec flagged this: + // G204 (CWE-78): Subprocess launched with function call as argument or command arguments + // Disabling as we trust the source of the variable. + // #nosec + // nosemgrep: go.lang.security.audit.dangerous-exec-command.dangerous-exec-command + command := exec.Command(wasmtools, args...) + wasmtoolsOutput, err := command.Output() + if err != nil && d.Verbose() { + text.Info(d.Output, errMsg, err) + } + if len(wasmtoolsOutput) == 0 { + return nil + } + + // Make a backup of the original Wasm binary (before being annotated). + originalBin, err := os.ReadFile(binWasmPath) + if err != nil { + return err + } + + // Overwrite the original Wasm binary with the annotated version. + // + // G302 (CWE-276): Expect file permissions to be 0600 or less + // gosec flagged this: + // Disabling as we want all users to be able to execute this binary. + // #nosec + err = os.WriteFile(binWasmPath, wasmtoolsOutput, 0o777) + if err != nil { + if d.Verbose() { + text.Info(d.Output, errMsg, err) + } + + // Restore the original Wasm binary. + // + // G302 (CWE-276): Expect file permissions to be 0600 or less + // gosec flagged this: + // Disabling as we want all users to be able to execute this binary. + // #nosec + err = os.WriteFile(binWasmPath, originalBin, 0o777) + if err != nil { + return fmt.Errorf("failed to restore %s: %w", binWasmPath, err) + } + } + + return nil +} + +// GetWasmTools returns the path to the wasm-tools binary. +// If there is no version installed, install the latest version. +// If there is a version installed, update to the latest version if not already. +// +// But only update the version if the CLI installed it. +// Otherwise updating a binary in the $PATH could cause problems if it's managed +// by a third-party software management tool like Homebrew, MacPorts etc. +func GetWasmTools(spinner text.Spinner, out io.Writer, wasmtoolsVersioner github.AssetVersioner, g *global.Data) (binPath string, err error) { + binPath, err = exec.LookPath("wasm-tools") + if err == nil { + if g.Verbose() { + text.Info(out, "\nUsing wasm-tools binary found in user $PATH\n\n") + } + return binPath, nil + } + if g.Verbose() { + text.Info(out, "\nFailed to lookup wasm-tools binary in user $PATH. We'll attempt to locate it inside a Fastly CLI managed directory.") + } + + binPath = wasmtoolsVersioner.InstallPath() + + // NOTE: When checking if wasm-tools is installed we don't use $PATH. + // + // $PATH is unreliable across OS platforms, but also we actually install + // wasm-tools in the same location as the CLI's app config, which means it + // wouldn't be found in the $PATH any way. We could pass the path for the app + // config into exec.LookPath() but it's simpler to attempt executing the binary. + // + // gosec flagged this: + // G204 (CWE-78): Subprocess launched with variable + // Disabling as the variables come from trusted sources. + // #nosec + // nosemgrep + c := exec.Command(binPath, "--version") + + var installedVersion string + + stdoutStderr, err := c.CombinedOutput() + if err != nil { + g.ErrLog.Add(err) + } else { + // Check the version output has the expected format: `wasm-tools 1.0.40` + installedVersion = strings.TrimSpace(string(stdoutStderr)) + segs := strings.Split(installedVersion, " ") + if len(segs) < 2 { + return binPath, ErrWasmtoolsNotFound + } + installedVersion = segs[1] + } + + if installedVersion == "" { + if g.Verbose() { + text.Info(out, "\nwasm-tools is not already installed, so we will install the latest version.\n\n") + } + err = installLatestWasmtools(binPath, spinner, wasmtoolsVersioner) + if err != nil { + g.ErrLog.Add(err) + return binPath, err + } + + latestVersion, err := wasmtoolsVersioner.LatestVersion() + if err != nil { + return binPath, fmt.Errorf("failed to retrieve wasm-tools latest version: %w", err) + } + + g.Config.WasmTools.LatestVersion = latestVersion + g.Config.WasmTools.LastChecked = time.Now().Format(time.RFC3339) + + err = g.Config.Write(g.ConfigPath) + if err != nil { + return binPath, err + } + } + + if installedVersion != "" { + err = updateWasmtools(binPath, spinner, out, g, wasmtoolsVersioner, installedVersion) + if err != nil { + g.ErrLog.Add(err) + return binPath, err + } + } + + err = github.SetBinPerms(binPath) + if err != nil { + g.ErrLog.Add(err) + return binPath, err + } + + return binPath, nil +} + +func installLatestWasmtools(binPath string, spinner text.Spinner, wasmtoolsVersioner github.AssetVersioner) error { + return spinner.Process("Fetching latest wasm-tools release", func(_ *text.SpinnerWrapper) error { + tmpBin, err := wasmtoolsVersioner.DownloadLatest() + if err != nil { + return fmt.Errorf("failed to download latest wasm-tools release: %w", err) + } + defer os.RemoveAll(tmpBin) + if err := os.Rename(tmpBin, binPath); err != nil { + if err := filesystem.CopyFile(tmpBin, binPath); err != nil { + return fmt.Errorf("failed to move wasm-tools binary to accessible location: %w", err) + } + } + return nil + }) +} + +func updateWasmtools( + binPath string, + spinner text.Spinner, + out io.Writer, + g *global.Data, + wasmtoolsVersioner github.AssetVersioner, + installedVersion string, +) error { + cfg := g.Config + cfgPath := g.ConfigPath + + // NOTE: We shouldn't see LastChecked with no value if wasm-tools installed. + if cfg.WasmTools.LastChecked == "" { + cfg.WasmTools.LastChecked = time.Now().Format(time.RFC3339) + if err := cfg.Write(cfgPath); err != nil { + return err + } + } + if !check.Stale(cfg.WasmTools.LastChecked, cfg.WasmTools.TTL) { + if g.Verbose() { + text.Info(out, "\nwasm-tools is installed but the CLI config (`fastly config`) shows the TTL, checking for a newer version, hasn't expired.\n\n") + } + return nil + } + + var latestVersion string + err := spinner.Process("Checking latest wasm-tools release", func(_ *text.SpinnerWrapper) error { + var err error + latestVersion, err = wasmtoolsVersioner.LatestVersion() + if err != nil { + return fsterr.RemediationError{ + Inner: fmt.Errorf("error fetching latest version: %w", err), + Remediation: fsterr.NetworkRemediation, + } + } + return nil + }) + if err != nil { + return err + } + + cfg.WasmTools.LatestVersion = latestVersion + cfg.WasmTools.LastChecked = time.Now().Format(time.RFC3339) + + err = cfg.Write(cfgPath) + if err != nil { + return err + } + if g.Verbose() { + text.Info(out, "\nThe CLI config (`fastly config`) has been updated with the latest wasm-tools version: %s\n\n", latestVersion) + } + if installedVersion == latestVersion { + return nil + } + + return installLatestWasmtools(binPath, spinner, wasmtoolsVersioner) +} + +// identifyToolchain determines the programming language. +// +// It prioritises the --language flag over the manifest field. +// Will error if neither are provided. +// Lastly, it will normalise with a trim and lowercase. +func identifyToolchain(c *BuildCommand) (string, error) { + var toolchain string + + switch { + case c.Flags.Lang != "": + toolchain = c.Flags.Lang + case c.Globals.Manifest.File.Language != "": + toolchain = c.Globals.Manifest.File.Language + default: + return "", fmt.Errorf("language cannot be empty, please provide a language") + } + + return strings.ToLower(strings.TrimSpace(toolchain)), nil +} + +// language returns a pointer to a supported language. +// +// TODO: Fix the mess that is New()'s argument list. +func language(toolchain, manifestFilename string, c *BuildCommand, in io.Reader, out io.Writer, spinner text.Spinner) (*Language, error) { + var language *Language + switch toolchain { + case "go": + language = NewLanguage(&LanguageOptions{ + Name: "go", + SourceDirectory: GoSourceDirectory, + Toolchain: NewGo(c, in, manifestFilename, out, spinner), + }) + case "javascript": + language = NewLanguage(&LanguageOptions{ + Name: "javascript", + SourceDirectory: JsSourceDirectory, + Toolchain: NewJavaScript(c, in, manifestFilename, out, spinner), + }) + case "rust": + language = NewLanguage(&LanguageOptions{ + Name: "rust", + SourceDirectory: RustSourceDirectory, + Toolchain: NewRust(c, in, manifestFilename, out, spinner), + }) + case "other": + language = NewLanguage(&LanguageOptions{ + Name: "other", + Toolchain: NewOther(c, in, manifestFilename, out, spinner), + }) + default: + return nil, fmt.Errorf("unsupported language %s", toolchain) + } + + return language, nil +} + +// binDir ensures a ./bin directory exists. +// The directory is required so a main.wasm can be placed inside it. +func binDir(c *BuildCommand) error { + if c.Globals.Verbose() { + text.Info(c.Globals.Output, "\nCreating ./bin directory (for Wasm binary)\n\n") + } + dir, err := os.Getwd() + if err != nil { + c.Globals.ErrLog.Add(err) + return fmt.Errorf("failed to identify the current working directory: %w", err) + } + binDir := filepath.Join(dir, "bin") + if err := filesystem.MakeDirectoryIfNotExists(binDir); err != nil { + c.Globals.ErrLog.Add(err) + return fmt.Errorf("failed to create bin directory: %w", err) + } + return nil +} + +// CreatePackageArchive packages build artifacts as a Fastly package. +// The package must be a GZipped Tar archive. +// +// Due to a behavior of archiver.Archive() which recursively writes all files in +// a provided directory to the archive we first copy our input files to a +// temporary directory to ensure only the specified files are included and not +// any in the directory which may be ignored. +func CreatePackageArchive(files []string, destination string) error { + // Create temporary directory to copy files into. + p := make([]byte, 8) + n, err := rand.Read(p) + if err != nil { + return fmt.Errorf("error creating temporary directory: %w", err) + } + + tmpDir := filepath.Join( + os.TempDir(), + fmt.Sprintf("fastly-build-%x", p[:n]), + ) + + if err := os.MkdirAll(tmpDir, 0o700); err != nil { + return fmt.Errorf("error creating temporary directory: %w", err) + } + defer os.RemoveAll(tmpDir) + + // Create implicit top-level directory within temp which will become the + // root of the archive. This replaces the `tar.ImplicitTopLevelFolder` + // behavior. + dir := filepath.Join(tmpDir, FileNameWithoutExtension(destination)) + if err := os.Mkdir(dir, 0o700); err != nil { + return fmt.Errorf("error creating temporary directory: %w", err) + } + + for _, src := range files { + dst := filepath.Join(dir, src) + if err = filesystem.CopyFile(src, dst); err != nil { + return fmt.Errorf("error copying file: %w", err) + } + } + + tar := archiver.NewTarGz() + tar.OverwriteExisting = true // + tar.MkdirAll = true // make destination directory if it doesn't exist + + return tar.Archive([]string{dir}, destination) +} + +// FileNameWithoutExtension returns a filename with its extension stripped. +func FileNameWithoutExtension(filename string) string { + base := filepath.Base(filename) + firstDot := strings.Index(base, ".") + if firstDot > -1 { + return base[:firstDot] + } + return base +} + +// GetIgnoredFiles reads the .fastlyignore file line-by-line and expands the +// glob pattern into a map containing all files it matches. If no ignore file +// is present it returns an empty map. +func GetIgnoredFiles(filePath string) (files map[string]bool, err error) { + files = make(map[string]bool) + + if !filesystem.FileExists(filePath) { + return files, nil + } + + // gosec flagged this: + // G304 (CWE-22): Potential file inclusion via variable + // Disabling as we trust the source of the filepath variable as it comes + // from the IgnoreFilePath constant. + /* #nosec */ + file, err := os.Open(filePath) + if err != nil { + return files, err + } + defer func() { + cerr := file.Close() + if err == nil { + err = cerr + } + }() + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + glob := strings.TrimSpace(scanner.Text()) + globFiles, err := filepath.Glob(glob) + if err != nil { + return files, fmt.Errorf("parsing glob %s: %w", glob, err) + } + for _, f := range globFiles { + files[f] = true + } + } + + if err := scanner.Err(); err != nil { + return files, fmt.Errorf("reading %s file: %w", filePath, err) + } + + return files, nil +} + +// GetNonIgnoredFiles walks a filepath and returns all files that don't exist in +// the provided ignore files map. +func GetNonIgnoredFiles(base string, ignoredFiles map[string]bool) ([]string, error) { + var files []string + err := filepath.Walk(base, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + return nil + } + if ignoredFiles[path] { + return nil + } + files = append(files, path) + return nil + }) + + return files, err +} + +// bytesToMB converts the runtime.MemStats.HeapAlloc bytes into megabytes. +func bytesToMB(bytes uint64) uint64 { + return uint64(math.Round(float64(bytes) / (1024 * 1024))) +} + +// bucketMB determines a consistent bucket size for heap allocation. +// NOTE: This is to avoid building a package with a fluctuating hashsum. +// e.g. `fastly compute hash-files` should be consistent unless memory increase is significant. +func bucketMB(mb uint64) string { + switch { + case mb < 2: + return "<2" + case mb >= 2 && mb < 5: + return "2-5" + case mb >= 5 && mb < 10: + return "5-10" + case mb >= 10 && mb < 20: + return "10-20" + case mb >= 20 && mb < 30: + return "20-30" + case mb >= 30 && mb < 40: + return "30-40" + case mb >= 40 && mb < 50: + return "40-50" + default: + return ">50" + } +} + +// DataCollection represents data annotated onto the Wasm binary. +type DataCollection struct { + BuildInfo DataCollectionBuildInfo `json:"build_info,omitempty"` + MachineInfo DataCollectionMachineInfo `json:"machine_info,omitempty"` + PackageInfo DataCollectionPackageInfo `json:"package_info,omitempty"` + ScriptInfo DataCollectionScriptInfo `json:"script_info,omitempty"` +} + +// DataCollectionBuildInfo represents build data annotated onto the Wasm binary. +type DataCollectionBuildInfo struct { + MemoryHeapAlloc string `json:"mem_heap_alloc,omitempty"` +} + +// DataCollectionMachineInfo represents machine data annotated onto the Wasm binary. +type DataCollectionMachineInfo struct { + Arch string `json:"arch,omitempty"` + CPUs int `json:"cpus,omitempty"` + Compiler string `json:"compiler,omitempty"` + GoVersion string `json:"go_version,omitempty"` + OS string `json:"os,omitempty"` +} + +// DataCollectionPackageInfo represents package data annotated onto the Wasm binary. +type DataCollectionPackageInfo struct { + // ClonedFrom indicates if the Starter Kit used was cloned from a specific + // repository (e.g. using the `compute init` --from flag). + ClonedFrom string `json:"cloned_from,omitempty"` + // Packages is a map where the key is the name of the package and the value is + // the package version. + Packages map[string]string `json:"packages,omitempty"` +} + +// DataCollectionScriptInfo represents script data annotated onto the Wasm binary. +type DataCollectionScriptInfo struct { + DefaultBuildUsed bool `json:"default_build_used,omitempty"` + BuildScript string `json:"build_script,omitempty"` + EnvVars []string `json:"env_vars,omitempty"` + PostInitScript string `json:"post_init_script,omitempty"` + PostBuildScript string `json:"post_build_script,omitempty"` +} diff --git a/pkg/commands/compute/build_test.go b/pkg/commands/compute/build_test.go new file mode 100644 index 000000000..44a0a9a8a --- /dev/null +++ b/pkg/commands/compute/build_test.go @@ -0,0 +1,862 @@ +package compute_test + +import ( + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/fastly/cli/pkg/app" + "github.com/fastly/cli/pkg/commands/compute" + "github.com/fastly/cli/pkg/config" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" + "github.com/fastly/cli/pkg/threadsafe" +) + +func TestBuildRust(t *testing.T) { + if os.Getenv("TEST_COMPUTE_BUILD_RUST") == "" && os.Getenv("TEST_COMPUTE_BUILD") == "" { + t.Log("skipping test") + t.Skip("Set TEST_COMPUTE_BUILD to run this test") + } + + args := testutil.SplitArgs + + scenarios := []struct { + name string + args []string + applicationConfig *config.File // a pointer so we can assert if configured + fastlyManifest string + cargoManifest string + wantError string + wantRemediationError string + wantOutput []string + }{ + { + name: "no fastly.toml manifest", + args: args("compute build"), + wantError: "error reading fastly.toml", + wantRemediationError: "Run `fastly compute init` to ensure a correctly configured manifest.", + }, + { + name: "empty language", + args: args("compute build"), + fastlyManifest: ` + manifest_version = 2 + name = "test"`, + wantError: "language cannot be empty, please provide a language", + }, + { + name: "unknown language", + args: args("compute build"), + fastlyManifest: ` + manifest_version = 2 + name = "test" + language = "foobar"`, + wantError: "unsupported language foobar", + }, + // The following test validates that the project compiles successfully even + // though the fastly.toml manifest has no build script. There should be a + // default build script inserted and it should use the same name as the + // project/package name in the Cargo.toml. + // + // NOTE: This test passes --verbose so we can validate specific outputs. + { + name: "build script inserted dynamically when missing", + args: args("compute build --verbose"), + applicationConfig: &config.File{ + Profiles: testutil.TokenProfile(), + Language: config.Language{ + Rust: config.Rust{ + ToolchainConstraint: ">= 1.78.0", + WasmWasiTarget: "wasm32-wasip1", + }, + }, + }, + cargoManifest: ` + [package] + name = "my-project" + version = "0.1.0" + + [dependencies] + fastly = "=0.6.0"`, + fastlyManifest: ` + manifest_version = 2 + name = "test" + language = "rust"`, + wantOutput: []string{ + "No [scripts.build] found in fastly.toml.", // requires --verbose + "The following default build command for", + "cargo build --bin my-project", + }, + }, + { + name: "build error", + args: args("compute build"), + applicationConfig: &config.File{ + Profiles: testutil.TokenProfile(), + Language: config.Language{ + Rust: config.Rust{ + ToolchainConstraint: ">= 1.78.0", + WasmWasiTarget: "wasm32-wasip1", + }, + }, + }, + cargoManifest: ` + [package] + name = "fastly-compute-project" + version = "0.1.0" + + [dependencies] + fastly = "=0.6.0"`, + fastlyManifest: ` + manifest_version = 2 + name = "test" + language = "rust" + + [scripts] + build = "echo no compilation happening"`, + wantRemediationError: compute.DefaultBuildErrorRemediation, + }, + { + name: "wasmwasi target error", + args: args("compute build --verbose"), + applicationConfig: &config.File{ + Profiles: testutil.TokenProfile(), + Language: config.Language{ + Rust: config.Rust{ + ToolchainConstraint: ">= 1.78.0", + WasmWasiTarget: "wasm32-wasi", + }, + }, + }, + cargoManifest: ` + [package] + name = "fastly-compute-project" + version = "0.1.0" + + [dependencies] + fastly = "=0.6.0"`, + fastlyManifest: fmt.Sprintf(` + manifest_version = 2 + name = "test" + language = "rust" + + [scripts] + build = "%s"`, fmt.Sprintf(compute.RustDefaultBuildCommand, compute.RustDefaultPackageName, compute.RustDefaultWasmWasiTarget)), + wantError: "the default build in .fastly/config.toml should produce a wasm32-wasip1 binary, but was instead set to produce a wasm32-wasi binary", + }, + // NOTE: This test passes --verbose so we can validate specific outputs. + { + name: "successful build", + args: args("compute build --verbose"), + applicationConfig: &config.File{ + Profiles: testutil.TokenProfile(), + Language: config.Language{ + Rust: config.Rust{ + ToolchainConstraint: ">= 1.78.0", + WasmWasiTarget: "wasm32-wasip1", + }, + }, + }, + cargoManifest: ` + [package] + name = "fastly-compute-project" + version = "0.1.0" + + [dependencies] + fastly = "=0.6.0"`, + fastlyManifest: fmt.Sprintf(` + manifest_version = 2 + name = "test" + language = "rust" + + [scripts] + build = "%s"`, fmt.Sprintf(compute.RustDefaultBuildCommand, compute.RustDefaultPackageName, compute.RustDefaultWasmWasiTarget)), + wantOutput: []string{ + "Creating ./bin directory (for Wasm binary)", + "Built package", + }, + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(testcase.name, func(t *testing.T) { + // We're going to chdir to a build environment, + // so save the PWD to return to, afterwards. + pwd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + + wasmtoolsBinName := "wasm-tools" + + // Windows was having issues when trying to move a tmpBin file (which + // represents the latest binary downloaded from GitHub) to binPath (which + // represents the existing binary installed on a user's machine). + // + // The problem was, for the sake of the tests, I just create one file + // `wasmtoolsBinName` and used that for both `tmpBin` and `binPath` and + // this works fine on *nix systems. But once Windows did `os.Rename()` and + // move tmpBin to binPath it would no longer be able to set permissions on + // the binPath because it didn't think the file existed any more. My guess + // is that moving a file over itself causes Windows to remove the file. + // + // So to work around that issue I just create two separate files because + // in reality that's what the CLI will be dealing with. I only used one + // file for the sake of test case convenience (which ironically became + // very INCONVENIENT when the tests started unexpectedly failing on + // Windows and caused me a long time debugging). + latestDownloaded := wasmtoolsBinName + "-latest-downloaded" + + // Create test environment + rootdir := testutil.NewEnv(testutil.EnvOpts{ + T: t, + Copy: []testutil.FileIO{ + {Src: filepath.Join("testdata", "build", "rust", "Cargo.lock"), Dst: "Cargo.lock"}, + {Src: filepath.Join("testdata", "build", "rust", "Cargo.toml"), Dst: "Cargo.toml"}, + {Src: filepath.Join("testdata", "build", "rust", "src", "main.rs"), Dst: filepath.Join("src", "main.rs")}, + {Src: filepath.Join("testdata", "deploy", "pkg", "package.tar.gz"), Dst: filepath.Join("pkg", "package.tar.gz")}, + }, + Write: []testutil.FileIO{ + {Src: `#!/usr/bin/env bash + echo wasm-tools 1.0.4`, Dst: wasmtoolsBinName, Executable: true}, + {Src: `#!/usr/bin/env bash + echo wasm-tools 2.0.0`, Dst: latestDownloaded, Executable: true}, + {Src: testcase.fastlyManifest, Dst: manifest.Filename}, + {Src: testcase.cargoManifest, Dst: "Cargo.toml"}, + }, + }) + defer os.RemoveAll(rootdir) + wasmtoolsBinPath := filepath.Join(rootdir, wasmtoolsBinName) + + // Before running the test, chdir into the build environment. + // When we're done, chdir back to our original location. + // This is so we can reliably copy the testdata/ fixtures. + if err := os.Chdir(rootdir); err != nil { + t.Fatal(err) + } + defer func() { + _ = os.Chdir(pwd) + }() + + var stdout threadsafe.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + if testcase.applicationConfig != nil { + opts.Config = *testcase.applicationConfig + } + opts.Versioners = global.Versioners{ + WasmTools: mock.AssetVersioner{ + AssetVersion: "1.2.3", + BinaryFilename: wasmtoolsBinName, + DownloadOK: true, + DownloadedFile: latestDownloaded, + InstallFilePath: wasmtoolsBinPath, // avoid overwriting developer's actual wasm-tools install + }, + } + return opts, nil + } + err = app.Run(testcase.args, nil) + + t.Log(stdout.String()) + + testutil.AssertRemediationErrorContains(t, err, testcase.wantRemediationError) + + // NOTE: Some errors we want to assert only the remediation. + // e.g. a 'stat' error isn't the same across operating systems/platforms. + if testcase.wantError != "" { + testutil.AssertErrorContains(t, err, testcase.wantError) + } + for _, s := range testcase.wantOutput { + testutil.AssertStringContains(t, stdout.String(), s) + } + }) + } +} + +func TestBuildGo(t *testing.T) { + if os.Getenv("TEST_COMPUTE_BUILD_GO") == "" && os.Getenv("TEST_COMPUTE_BUILD") == "" { + t.Log("skipping test") + t.Skip("Set TEST_COMPUTE_BUILD to run this test") + } + + args := testutil.SplitArgs + + scenarios := []struct { + name string + args []string + applicationConfig *config.File + fastlyManifest string + wantError string + wantRemediationError string + wantOutput []string + }{ + { + name: "no fastly.toml manifest", + args: args("compute build"), + wantError: "error reading fastly.toml", + wantRemediationError: "Run `fastly compute init` to ensure a correctly configured manifest.", + }, + { + name: "empty language", + args: args("compute build"), + fastlyManifest: ` + manifest_version = 2 + name = "test"`, + wantError: "language cannot be empty, please provide a language", + }, + { + name: "unknown language", + args: args("compute build"), + fastlyManifest: ` + manifest_version = 2 + name = "test" + language = "foobar"`, + wantError: "unsupported language foobar", + }, + // The following test validates that the project compiles successfully even + // though the fastly.toml manifest has no build script. There should be a + // default build script inserted. + // + // NOTE: This test passes --verbose so we can validate specific outputs. + { + name: "build success", + args: args("compute build --verbose"), + applicationConfig: &config.File{ + Profiles: testutil.TokenProfile(), + Language: config.Language{ + Go: config.Go{ + TinyGoConstraint: ">= 0.26.0-0", + ToolchainConstraintTinyGo: ">= 1.18", + ToolchainConstraint: ">= 1.21", + }, + }, + }, + fastlyManifest: ` + manifest_version = 2 + name = "test" + language = "go" + [scripts] + build = "go build -o bin/main.wasm ./" + env_vars = ["GOARCH=wasm", "GOOS=wasip1"] + `, + wantOutput: []string{ + "The Fastly CLI build step requires a go version '>= 1.21'", + "Build script to execute", + "Build environment variables set", + "GOARCH=wasm GOOS=wasip1", + "Creating ./bin directory (for Wasm binary)", + "Built package", + }, + }, + // The following test case is expected to fail because we specify a custom + // build script that doesn't actually produce a ./bin/main.wasm + { + name: "build error", + args: args("compute build"), + applicationConfig: &config.File{ + Profiles: testutil.TokenProfile(), + Language: config.Language{ + Go: config.Go{ + TinyGoConstraint: ">= 0.26.0-0", + ToolchainConstraintTinyGo: ">= 1.18", + ToolchainConstraint: ">= 1.21", + }, + }, + }, + fastlyManifest: ` + manifest_version = 2 + name = "test" + language = "go" + + [scripts] + build = "echo no compilation happening"`, + wantRemediationError: compute.DefaultBuildErrorRemediation, + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(testcase.name, func(t *testing.T) { + // We're going to chdir to a build environment, + // so save the PWD to return to, afterwards. + pwd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + + wasmtoolsBinName := "wasm-tools" + + // Windows was having issues when trying to move a tmpBin file (which + // represents the latest binary downloaded from GitHub) to binPath (which + // represents the existing binary installed on a user's machine). + // + // The problem was, for the sake of the tests, I just create one file + // `wasmtoolsBinName` and used that for both `tmpBin` and `binPath` and + // this works fine on *nix systems. But once Windows did `os.Rename()` and + // move tmpBin to binPath it would no longer be able to set permissions on + // the binPath because it didn't think the file existed any more. My guess + // is that moving a file over itself causes Windows to remove the file. + // + // So to work around that issue I just create two separate files because + // in reality that's what the CLI will be dealing with. I only used one + // file for the sake of test case convenience (which ironically became + // very INCONVENIENT when the tests started unexpectedly failing on + // Windows and caused me a long time debugging). + latestDownloaded := wasmtoolsBinName + "-latest-downloaded" + + // Create test environment + rootdir := testutil.NewEnv(testutil.EnvOpts{ + T: t, + Copy: []testutil.FileIO{ + {Src: filepath.Join("testdata", "build", "go", "go.mod"), Dst: "go.mod"}, + {Src: filepath.Join("testdata", "build", "go", "main.go"), Dst: "main.go"}, + }, + Write: []testutil.FileIO{ + {Src: `#!/usr/bin/env bash + echo wasm-tools 1.0.4`, Dst: wasmtoolsBinName, Executable: true}, + {Src: `#!/usr/bin/env bash + echo wasm-tools 2.0.0`, Dst: latestDownloaded, Executable: true}, + {Src: testcase.fastlyManifest, Dst: manifest.Filename}, + }, + }) + defer os.RemoveAll(rootdir) + wasmtoolsBinPath := filepath.Join(rootdir, wasmtoolsBinName) + + // Before running the test, chdir into the build environment. + // When we're done, chdir back to our original location. + // This is so we can reliably copy the testdata/ fixtures. + if err := os.Chdir(rootdir); err != nil { + t.Fatal(err) + } + defer func() { + _ = os.Chdir(pwd) + }() + + var stdout threadsafe.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + if testcase.applicationConfig != nil { + opts.Config = *testcase.applicationConfig + } + opts.Versioners = global.Versioners{ + WasmTools: mock.AssetVersioner{ + AssetVersion: "1.2.3", + BinaryFilename: wasmtoolsBinName, + DownloadOK: true, + DownloadedFile: latestDownloaded, + InstallFilePath: wasmtoolsBinPath, // avoid overwriting developer's actual wasm-tools install + }, + } + return opts, nil + } + err = app.Run(testcase.args, nil) + + t.Log(stdout.String()) + + testutil.AssertRemediationErrorContains(t, err, testcase.wantRemediationError) + + // NOTE: Some errors we want to assert only the remediation. + // e.g. a 'stat' error isn't the same across operating systems/platforms. + if testcase.wantError != "" { + testutil.AssertErrorContains(t, err, testcase.wantError) + } + for _, s := range testcase.wantOutput { + testutil.AssertStringContains(t, stdout.String(), s) + } + }) + } +} + +func TestBuildJavaScript(t *testing.T) { + if os.Getenv("TEST_COMPUTE_BUILD_JAVASCRIPT") == "" && os.Getenv("TEST_COMPUTE_BUILD") == "" { + t.Log("skipping test") + t.Skip("Set TEST_COMPUTE_BUILD to run this test") + } + + args := testutil.SplitArgs + + scenarios := []struct { + name string + args []string + fastlyManifest string + wantError string + wantRemediationError string + wantOutput []string + npmInstall bool + versioners *global.Versioners + }{ + { + name: "no fastly.toml manifest", + args: args("compute build"), + wantError: "error reading fastly.toml", + wantRemediationError: "Run `fastly compute init` to ensure a correctly configured manifest.", + }, + { + name: "empty language", + args: args("compute build"), + fastlyManifest: ` + manifest_version = 2 + name = "test"`, + wantError: "language cannot be empty, please provide a language", + }, + { + name: "unknown language", + args: args("compute build"), + fastlyManifest: ` + manifest_version = 2 + name = "test" + language = "foobar"`, + wantError: "unsupported language foobar", + }, + // The following test validates that the project compiles successfully even + // though the fastly.toml manifest has no build script. There should be a + // default build script inserted. + // + // NOTE: This test passes --verbose so we can validate specific outputs. + { + name: "build script inserted dynamically when missing", + args: args("compute build --verbose"), + fastlyManifest: ` + manifest_version = 2 + name = "test" + language = "javascript"`, + wantOutput: []string{ + "No [scripts.build] found in fastly.toml.", // requires --verbose + "The following default build command for", + "npm exec webpack", // our testdata package.json references webpack + }, + }, + { + name: "build error", + args: args("compute build"), + fastlyManifest: ` + manifest_version = 2 + name = "test" + language = "javascript" + + [scripts] + build = "echo no compilation happening"`, + wantRemediationError: compute.DefaultBuildErrorRemediation, + }, + // NOTE: This test passes --verbose so we can validate specific outputs. + { + name: "successful build", + args: args("compute build --verbose"), + fastlyManifest: fmt.Sprintf(` + manifest_version = 2 + name = "test" + language = "javascript" + + [scripts] + build = "%s"`, compute.JsDefaultBuildCommandForWebpack), + wantOutput: []string{ + "Creating ./bin directory (for Wasm binary)", + "Built package", + }, + npmInstall: true, + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(testcase.name, func(t *testing.T) { + // We're going to chdir to a build environment, + // so save the PWD to return to, afterwards. + pwd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + + wasmtoolsBinName := "wasm-tools" + + // Windows was having issues when trying to move a tmpBin file (which + // represents the latest binary downloaded from GitHub) to binPath (which + // represents the existing binary installed on a user's machine). + // + // The problem was, for the sake of the tests, I just create one file + // `wasmtoolsBinName` and used that for both `tmpBin` and `binPath` and + // this works fine on *nix systems. But once Windows did `os.Rename()` and + // move tmpBin to binPath it would no longer be able to set permissions on + // the binPath because it didn't think the file existed any more. My guess + // is that moving a file over itself causes Windows to remove the file. + // + // So to work around that issue I just create two separate files because + // in reality that's what the CLI will be dealing with. I only used one + // file for the sake of test case convenience (which ironically became + // very INCONVENIENT when the tests started unexpectedly failing on + // Windows and caused me a long time debugging). + latestDownloaded := wasmtoolsBinName + "-latest-downloaded" + + // Create test environment + rootdir := testutil.NewEnv(testutil.EnvOpts{ + T: t, + Copy: []testutil.FileIO{ + {Src: filepath.Join("testdata", "build", "javascript", "package.json"), Dst: "package.json"}, + {Src: filepath.Join("testdata", "build", "javascript", "webpack.config.js"), Dst: "webpack.config.js"}, + {Src: filepath.Join("testdata", "build", "javascript", "src", "index.js"), Dst: filepath.Join("src", "index.js")}, + }, + Write: []testutil.FileIO{ + {Src: `#!/usr/bin/env bash + echo wasm-tools 1.0.4`, Dst: wasmtoolsBinName, Executable: true}, + {Src: `#!/usr/bin/env bash + echo wasm-tools 2.0.0`, Dst: latestDownloaded, Executable: true}, + {Src: testcase.fastlyManifest, Dst: manifest.Filename}, + }, + }) + defer os.RemoveAll(rootdir) + wasmtoolsBinPath := filepath.Join(rootdir, wasmtoolsBinName) + + // Before running the test, chdir into the build environment. + // When we're done, chdir back to our original location. + // This is so we can reliably copy the testdata/ fixtures. + if err := os.Chdir(rootdir); err != nil { + t.Fatal(err) + } + defer func() { + _ = os.Chdir(pwd) + }() + + // NOTE: We only want to run `npm install` for the success case. + if testcase.npmInstall { + // gosec flagged this: + // G204 (CWE-78): Subprocess launched with variable + // Disabling as we control this command. + // #nosec + // nosemgrep + c := exec.Command("npm", "install") + + err = c.Run() + if err != nil { + t.Fatal(err) + } + } + + var stdout threadsafe.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.Versioners = global.Versioners{ + WasmTools: mock.AssetVersioner{ + AssetVersion: "1.2.3", + BinaryFilename: wasmtoolsBinName, + DownloadOK: true, + DownloadedFile: latestDownloaded, + InstallFilePath: wasmtoolsBinPath, // avoid overwriting developer's actual wasm-tools install + }, + } + return opts, nil + } + err = app.Run(testcase.args, nil) + + t.Log(stdout.String()) + + testutil.AssertRemediationErrorContains(t, err, testcase.wantRemediationError) + + // NOTE: Some errors we want to assert only the remediation. + // e.g. a 'stat' error isn't the same across operating systems/platforms. + if testcase.wantError != "" { + testutil.AssertErrorContains(t, err, testcase.wantError) + } + for _, s := range testcase.wantOutput { + testutil.AssertStringContains(t, stdout.String(), s) + } + }) + } +} + +// NOTE: TestBuildOther also validates the post_build settings. +func TestBuildOther(t *testing.T) { + args := testutil.SplitArgs + if os.Getenv("TEST_COMPUTE_BUILD") == "" { + t.Log("skipping test") + t.Skip("Set TEST_COMPUTE_BUILD to run this test") + } + + for _, testcase := range []struct { + args []string + dontWantOutput []string + fastlyManifest string + name string + stdin string + wantError string + wantOutput []string + wantRemediationError string + }{ + { + name: "stop build process", + args: args("compute build --language other"), + fastlyManifest: ` + manifest_version = 2 + name = "test" + [scripts] + build = "cp ./bin/test.main.wasm ./bin/main.wasm" + post_build = "echo doing a post build"`, + stdin: "N", + wantOutput: []string{ + "echo doing a post build", + "Do you want to run this now?", + }, + wantError: "build process stopped by user", + }, + // NOTE: All following tests pass --verbose so we can see post_build output. + { + name: "allow build process", + args: args("compute build --language other --verbose"), + fastlyManifest: ` + manifest_version = 2 + name = "test" + [scripts] + build = "cp ./bin/test.main.wasm ./bin/main.wasm" + post_build = "echo doing a post build"`, + stdin: "Y", + wantOutput: []string{ + "echo doing a post build", + "Do you want to run this now?", + "Built package", + }, + }, + { + name: "language pulled from manifest", + args: args("compute build --verbose"), + fastlyManifest: ` + manifest_version = 2 + name = "test" + language = "other" + [scripts] + build = "cp ./bin/test.main.wasm ./bin/main.wasm" + post_build = "echo doing a post build"`, + stdin: "Y", + wantOutput: []string{ + "echo doing a post build", + "Do you want to run this now?", + "Built package", + }, + }, + { + name: "avoid prompt confirmation", + args: args("compute build --auto-yes --language other --verbose"), + fastlyManifest: ` + manifest_version = 2 + name = "test" + [scripts] + build = "cp ./bin/test.main.wasm ./bin/main.wasm" + post_build = "echo doing a post build with no confirmation prompt && exit 1"`, // force an error so post_build is displayed to validate it was run. + wantOutput: []string{ + "doing a post build with no confirmation prompt", + }, + dontWantOutput: []string{ + "Do you want to run this now?", + }, + wantError: "exit status 1", // because we have to trigger an error to see the post_build output + }, + } { + t.Run(testcase.name, func(t *testing.T) { + // We're going to chdir to a build environment, + // so save the PWD to return to, afterwards. + pwd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + + wasmtoolsBinName := "wasm-tools" + + // Windows was having issues when trying to move a tmpBin file (which + // represents the latest binary downloaded from GitHub) to binPath (which + // represents the existing binary installed on a user's machine). + // + // The problem was, for the sake of the tests, I just create one file + // `wasmtoolsBinName` and used that for both `tmpBin` and `binPath` and + // this works fine on *nix systems. But once Windows did `os.Rename()` and + // move tmpBin to binPath it would no longer be able to set permissions on + // the binPath because it didn't think the file existed any more. My guess + // is that moving a file over itself causes Windows to remove the file. + // + // So to work around that issue I just create two separate files because + // in reality that's what the CLI will be dealing with. I only used one + // file for the sake of test case convenience (which ironically became + // very INCONVENIENT when the tests started unexpectedly failing on + // Windows and caused me a long time debugging). + latestDownloaded := wasmtoolsBinName + "-latest-downloaded" + + // Create test environment + // + // NOTE: Our only requirement is that there be a bin directory. The custom + // build script we're using in the test is not going to use any files in the + // directory (the script will just copy a test binary into the expected + // location of the final main.wasm binary). + // + // NOTE: We create a "valid" main.wasm file with a quick shell script. + // + // Previously we set the build script to "touch ./bin/main.wasm" but since + // adding Wasm validation this no longer works as it's an empty file. + // + // So we use the following script to produce a file that LOOKS valid but isn't. + // + // magic="\x00\x61\x73\x6d\x01\x00\x00\x00" + // printf "$magic" > ./pkg/commands/compute/testdata/main.wasm + rootdir := testutil.NewEnv(testutil.EnvOpts{ + T: t, + Copy: []testutil.FileIO{ + {Src: "./testdata/main.wasm", Dst: "bin/test.main.wasm"}, + }, + Write: []testutil.FileIO{ + {Src: `#!/usr/bin/env bash + echo wasm-tools 1.0.4`, Dst: wasmtoolsBinName, Executable: true}, + {Src: `#!/usr/bin/env bash + echo wasm-tools 2.0.0`, Dst: latestDownloaded, Executable: true}, + {Src: "mock content", Dst: "bin/testfile"}, + }, + }) + defer os.RemoveAll(rootdir) + wasmtoolsBinPath := filepath.Join(rootdir, wasmtoolsBinName) + + // Before running the test, chdir into the build environment. + // When we're done, chdir back to our original location. + // This is so we can reliably copy the testdata/ fixtures. + if err := os.Chdir(rootdir); err != nil { + t.Fatal(err) + } + defer func() { + _ = os.Chdir(pwd) + }() + + if testcase.fastlyManifest != "" { + if err := os.WriteFile(filepath.Join(rootdir, manifest.Filename), []byte(testcase.fastlyManifest), 0o600); err != nil { + t.Fatal(err) + } + } + + var stdout threadsafe.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.Input = strings.NewReader(testcase.stdin) // NOTE: build only has one prompt when dealing with a custom build + opts.Versioners = global.Versioners{ + WasmTools: mock.AssetVersioner{ + AssetVersion: "1.2.3", + BinaryFilename: wasmtoolsBinName, + DownloadOK: true, + DownloadedFile: latestDownloaded, + InstallFilePath: wasmtoolsBinPath, // avoid overwriting developer's actual wasm-tools install + }, + } + return opts, nil + } + err = app.Run(testcase.args, nil) + + t.Log(stdout.String()) + + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertRemediationErrorContains(t, err, testcase.wantRemediationError) + for _, s := range testcase.wantOutput { + testutil.AssertStringContains(t, stdout.String(), s) + } + for _, s := range testcase.dontWantOutput { + testutil.AssertStringDoesntContain(t, stdout.String(), s) + } + }) + } +} diff --git a/pkg/commands/compute/compute_mocks_test.go b/pkg/commands/compute/compute_mocks_test.go new file mode 100644 index 000000000..e78f72627 --- /dev/null +++ b/pkg/commands/compute/compute_mocks_test.go @@ -0,0 +1,201 @@ +package compute_test + +// NOTE: This file doesn't contain any tests. It only contains code that is +// shared across some of the other test files (mostly mocked API responses, but +// also a mocked HTTP client). + +import ( + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/testutil" +) + +func getServiceOK(_ *fastly.GetServiceInput) (*fastly.Service, error) { + return &fastly.Service{ + ServiceID: fastly.ToPointer("12345"), + Name: fastly.ToPointer("test"), + }, nil +} + +func createDomainOK(i *fastly.CreateDomainInput) (*fastly.Domain, error) { + return &fastly.Domain{ + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: i.Name, + }, nil +} + +func createBackendOK(i *fastly.CreateBackendInput) (*fastly.Backend, error) { + return &fastly.Backend{ + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: i.Name, + }, nil +} + +func createConfigStoreOK(i *fastly.CreateConfigStoreInput) (*fastly.ConfigStore, error) { + return &fastly.ConfigStore{ + Name: i.Name, + }, nil +} + +func updateConfigStoreItemOK(i *fastly.UpdateConfigStoreItemInput) (*fastly.ConfigStoreItem, error) { + return &fastly.ConfigStoreItem{ + Key: i.Key, + Value: i.Value, + }, nil +} + +func createDictionaryOK(i *fastly.CreateDictionaryInput) (*fastly.Dictionary, error) { + return &fastly.Dictionary{ + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: i.Name, + }, nil +} + +func createDictionaryItemOK(i *fastly.CreateDictionaryItemInput) (*fastly.DictionaryItem, error) { + return &fastly.DictionaryItem{ + ServiceID: fastly.ToPointer(i.ServiceID), + DictionaryID: fastly.ToPointer(i.DictionaryID), + ItemKey: i.ItemKey, + ItemValue: i.ItemValue, + }, nil +} + +func createKVStoreOK(i *fastly.CreateKVStoreInput) (*fastly.KVStore, error) { + return &fastly.KVStore{ + StoreID: "example-store", + Name: i.Name, + }, nil +} + +func createKVStoreItemOK(_ *fastly.InsertKVStoreKeyInput) error { + return nil +} + +func createResourceOK(_ *fastly.CreateResourceInput) (*fastly.Resource, error) { + return nil, nil +} + +func getPackageOk(i *fastly.GetPackageInput) (*fastly.Package, error) { + return &fastly.Package{ServiceID: fastly.ToPointer(i.ServiceID), ServiceVersion: fastly.ToPointer(i.ServiceVersion)}, nil +} + +func updatePackageOk(i *fastly.UpdatePackageInput) (*fastly.Package, error) { + return &fastly.Package{ServiceID: fastly.ToPointer(i.ServiceID), ServiceVersion: fastly.ToPointer(i.ServiceVersion)}, nil +} + +func updatePackageError(_ *fastly.UpdatePackageInput) (*fastly.Package, error) { + return nil, testutil.Err +} + +func activateVersionOk(i *fastly.ActivateVersionInput) (*fastly.Version, error) { + return &fastly.Version{ServiceID: fastly.ToPointer(i.ServiceID), Number: fastly.ToPointer(i.ServiceVersion)}, nil +} + +func updateVersionOk(i *fastly.UpdateVersionInput) (*fastly.Version, error) { + return &fastly.Version{ServiceID: fastly.ToPointer(i.ServiceID), Number: fastly.ToPointer(i.ServiceVersion), Comment: i.Comment}, nil +} + +func listDomainsOk(_ *fastly.ListDomainsInput) ([]*fastly.Domain, error) { + return []*fastly.Domain{ + {Name: fastly.ToPointer("https://directly-careful-coyote.edgecompute.app")}, + }, nil +} + +func listKVStoresOk(_ *fastly.ListKVStoresInput) (*fastly.ListKVStoresResponse, error) { + return &fastly.ListKVStoresResponse{ + Data: []fastly.KVStore{ + { + StoreID: "123", + Name: "store_one", + }, + { + StoreID: "456", + Name: "store_two", + }, + }, + }, nil +} + +func listKVStoresEmpty(_ *fastly.ListKVStoresInput) (*fastly.ListKVStoresResponse, error) { + return &fastly.ListKVStoresResponse{}, nil +} + +func getKVStoreOk(_ *fastly.GetKVStoreInput) (*fastly.KVStore, error) { + return &fastly.KVStore{ + StoreID: "123", + Name: "store_one", + }, nil +} + +func listSecretStoresOk(_ *fastly.ListSecretStoresInput) (*fastly.SecretStores, error) { + return &fastly.SecretStores{ + Data: []fastly.SecretStore{ + { + StoreID: "123", + Name: "store_one", + }, + { + StoreID: "456", + Name: "store_two", + }, + }, + }, nil +} + +func listSecretStoresEmpty(_ *fastly.ListSecretStoresInput) (*fastly.SecretStores, error) { + return &fastly.SecretStores{}, nil +} + +func getSecretStoreOk(_ *fastly.GetSecretStoreInput) (*fastly.SecretStore, error) { + return &fastly.SecretStore{ + StoreID: "123", + Name: "store_one", + }, nil +} + +func createSecretStoreOk(_ *fastly.CreateSecretStoreInput) (*fastly.SecretStore, error) { + return &fastly.SecretStore{ + StoreID: "123", + Name: "store_one", + }, nil +} + +func createSecretOk(_ *fastly.CreateSecretInput) (*fastly.Secret, error) { + return &fastly.Secret{ + Digest: []byte("123"), + Name: "foo", + }, nil +} + +func listConfigStoresOk(_ *fastly.ListConfigStoresInput) ([]*fastly.ConfigStore, error) { + return []*fastly.ConfigStore{ + { + StoreID: "123", + Name: "example", + }, + { + StoreID: "456", + Name: "example_two", + }, + }, nil +} + +func listConfigStoresEmpty(_ *fastly.ListConfigStoresInput) ([]*fastly.ConfigStore, error) { + return []*fastly.ConfigStore{}, nil +} + +func getConfigStoreOk(_ *fastly.GetConfigStoreInput) (*fastly.ConfigStore, error) { + return &fastly.ConfigStore{ + StoreID: "123", + Name: "example", + }, nil +} + +func getServiceDetailsWasm(_ *fastly.GetServiceInput) (*fastly.ServiceDetail, error) { + return &fastly.ServiceDetail{ + Type: fastly.ToPointer("wasm"), + }, nil +} diff --git a/pkg/commands/compute/compute_test.go b/pkg/commands/compute/compute_test.go new file mode 100644 index 000000000..b95ee39ed --- /dev/null +++ b/pkg/commands/compute/compute_test.go @@ -0,0 +1,467 @@ +package compute_test + +import ( + "os" + "path/filepath" + "reflect" + "testing" + + "github.com/mholt/archiver/v3" + + "github.com/fastly/kingpin" + + "github.com/fastly/cli/pkg/commands/compute" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/testutil" +) + +// TestFlagDivergencePublish validates that the manually curated list of flags +// within the `compute publish` command doesn't fall out of sync with the +// `compute build` and `compute deploy` commands from which publish is composed. +func TestFlagDivergencePublish(t *testing.T) { + var g global.Data + g.Manifest = &manifest.Data{} + acmd := kingpin.New("foo", "bar") + + rcmd := compute.NewRootCommand(acmd, &g) + bcmd := compute.NewBuildCommand(rcmd.CmdClause, &g) + dcmd := compute.NewDeployCommand(rcmd.CmdClause, &g) + pcmd := compute.NewPublishCommand(rcmd.CmdClause, &g, bcmd, dcmd) + + buildFlags := getFlags(bcmd.CmdClause) + deployFlags := getFlags(dcmd.CmdClause) + publishFlags := getFlags(pcmd.CmdClause) + + var ( + expect = make(map[string]int) + have = make(map[string]int) + ) + + // Some flags on `compute build` are unique to it. + // NOTE: There are no flags to ignore but I'm keeping the logic for future. + ignoreBuildFlags := []string{} + + iter := buildFlags.MapRange() + for iter.Next() { + flag := iter.Key().String() + if !ignoreFlag(ignoreBuildFlags, flag) { + expect[flag] = 1 + } + } + + iter = deployFlags.MapRange() + for iter.Next() { + expect[iter.Key().String()] = 1 + } + + iter = publishFlags.MapRange() + for iter.Next() { + have[iter.Key().String()] = 1 + } + + if !reflect.DeepEqual(expect, have) { + t.Fatalf("the flags between build/deploy and publish don't match\n\nexpect: %+v\nhave: %+v\n\n", expect, have) + } +} + +// TestFlagDivergenceServe validates that the manually curated list of flags +// within the `compute serve` command doesn't fall out of sync with the +// `compute build` command as `compute serve` delegates to build. +func TestFlagDivergenceServe(t *testing.T) { + var cfg global.Data + acmd := kingpin.New("foo", "bar") + + rcmd := compute.NewRootCommand(acmd, &cfg) + bcmd := compute.NewBuildCommand(rcmd.CmdClause, &cfg) + scmd := compute.NewServeCommand(rcmd.CmdClause, &cfg, bcmd) + + buildFlags := getFlags(bcmd.CmdClause) + serveFlags := getFlags(scmd.CmdClause) + + var ( + expect = make(map[string]int) + have = make(map[string]int) + ) + + // Some flags on `compute build` are unique to it. + // NOTE: There are no flags to ignore but I'm keeping the logic for future. + ignoreBuildFlags := []string{} + + iter := buildFlags.MapRange() + for iter.Next() { + flag := iter.Key().String() + if !ignoreFlag(ignoreBuildFlags, flag) { + expect[flag] = 1 + } + } + + // Some flags on `compute serve` are unique to it. + // We only want to be sure serve contains all build flags. + ignoreServeFlags := []string{ + "addr", + "debug", + "file", + "profile-guest", + "profile-guest-dir", + "skip-build", + "viceroy-args", + "viceroy-check", + "viceroy-path", + "watch", + "watch-dir", + } + + iter = serveFlags.MapRange() + for iter.Next() { + flag := iter.Key().String() + if !ignoreFlag(ignoreServeFlags, flag) { + have[flag] = 1 + } + } + + if !reflect.DeepEqual(expect, have) { + t.Fatalf("the flags between build and serve don't match\n\nexpect: %+v\nhave: %+v\n\n", expect, have) + } +} + +// TestFlagDivergenceHashSum validates that the manually curated list of flags +// within the `compute hashsum` command doesn't fall out of sync with the +// `compute build` command as `compute hashsum` delegates to build. +func TestFlagDivergenceHashSum(t *testing.T) { + var cfg global.Data + acmd := kingpin.New("foo", "bar") + + rcmd := compute.NewRootCommand(acmd, &cfg) + bcmd := compute.NewBuildCommand(rcmd.CmdClause, &cfg) + hcmd := compute.NewHashsumCommand(rcmd.CmdClause, &cfg, bcmd) + + buildFlags := getFlags(bcmd.CmdClause) + hashsumFlags := getFlags(hcmd.CmdClause) + + var ( + expect = make(map[string]int) + have = make(map[string]int) + ) + + // Some flags on `compute build` are unique to it. + // NOTE: There are no flags to ignore but I'm keeping the logic for future. + ignoreBuildFlags := []string{} + + iter := buildFlags.MapRange() + for iter.Next() { + flag := iter.Key().String() + if !ignoreFlag(ignoreBuildFlags, flag) { + expect[flag] = 1 + } + } + + // Some flags on `compute hashsum` are unique to it. + // We only want to be sure hashsum contains all build flags. + ignoreHashsumFlags := []string{ + "package", + "skip-build", + } + + iter = hashsumFlags.MapRange() + for iter.Next() { + flag := iter.Key().String() + if !ignoreFlag(ignoreHashsumFlags, flag) { + have[flag] = 1 + } + } + + if !reflect.DeepEqual(expect, have) { + t.Fatalf("the flags between build and hashsum don't match\n\nexpect: %+v\nhave: %+v\n\n", expect, have) + } +} + +// TestFlagDivergenceHashFiles validates that the manually curated list of flags +// within the `compute hashsum` command doesn't fall out of sync with the +// `compute build` command as `compute hashsum` delegates to build. +func TestFlagDivergenceHashFiles(t *testing.T) { + var cfg global.Data + acmd := kingpin.New("foo", "bar") + + rcmd := compute.NewRootCommand(acmd, &cfg) + bcmd := compute.NewBuildCommand(rcmd.CmdClause, &cfg) + hcmd := compute.NewHashFilesCommand(rcmd.CmdClause, &cfg, bcmd) + + buildFlags := getFlags(bcmd.CmdClause) + hashfilesFlags := getFlags(hcmd.CmdClause) + + var ( + expect = make(map[string]int) + have = make(map[string]int) + ) + + // Some flags on `compute build` are unique to it. + // NOTE: There are no flags to ignore but I'm keeping the logic for future. + ignoreBuildFlags := []string{} + + iter := buildFlags.MapRange() + for iter.Next() { + flag := iter.Key().String() + if !ignoreFlag(ignoreBuildFlags, flag) { + expect[flag] = 1 + } + } + + // Some flags on `compute hashsum` are unique to it. + // We only want to be sure hashsum contains all build flags. + ignoreHashfilesFlags := []string{ + "package", + "skip-build", + } + + iter = hashfilesFlags.MapRange() + for iter.Next() { + flag := iter.Key().String() + if !ignoreFlag(ignoreHashfilesFlags, flag) { + have[flag] = 1 + } + } + + if !reflect.DeepEqual(expect, have) { + t.Fatalf("the flags between build and hash-files don't match\n\nexpect: %+v\nhave: %+v\n\n", expect, have) + } +} + +// ignoreFlag indicates if needle should be omitted from comparison. +func ignoreFlag(ignore []string, flag string) bool { + for _, i := range ignore { + if i == flag { + return true + } + } + return false +} + +func getFlags(cmd *kingpin.CmdClause) reflect.Value { + return reflect.ValueOf(cmd).Elem().FieldByName("cmdMixin").FieldByName("flagGroup").Elem().FieldByName("long") +} + +func TestCreatePackageArchive(t *testing.T) { + // we're going to chdir to a build environment, + // so save the pwd to return to, afterwards. + pwd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + + // Create test environment + rootdir := testutil.NewEnv(testutil.EnvOpts{ + T: t, + Copy: []testutil.FileIO{ + {Src: filepath.Join("testdata", "build", "rust", "Cargo.lock"), Dst: "Cargo.lock"}, + {Src: filepath.Join("testdata", "build", "rust", "Cargo.toml"), Dst: "Cargo.toml"}, + {Src: filepath.Join("testdata", "build", "rust", "src", "main.rs"), Dst: filepath.Join("src", "main.rs")}, + }, + }) + defer os.RemoveAll(rootdir) + + // before running the test, chdir into the build environment. + // when we're done, chdir back to our original location. + // this is so we can reliably copy the testdata/ fixtures. + if err := os.Chdir(rootdir); err != nil { + t.Fatal(err) + } + defer func() { + _ = os.Chdir(pwd) + }() + + destination := "cli.tar.gz" + + err = compute.CreatePackageArchive([]string{"Cargo.toml", "Cargo.lock", "src/main.rs"}, destination) + testutil.AssertNoError(t, err) + + var files, directories []string + if err := archiver.Walk(destination, func(f archiver.File) error { + if f.IsDir() { + directories = append(directories, f.Name()) + } else { + files = append(files, f.Name()) + } + return nil + }); err != nil { + t.Fatal(err) + } + + wantDirectories := []string{"cli", "src"} + testutil.AssertEqual(t, wantDirectories, directories) + + wantFiles := []string{"Cargo.lock", "Cargo.toml", "main.rs"} + testutil.AssertEqual(t, wantFiles, files) +} + +func TestFileNameWithoutExtension(t *testing.T) { + for _, testcase := range []struct { + input string + wantOutput string + }{ + { + input: "foo/bar/baz.tar.gz", + wantOutput: "baz", + }, + { + input: "foo/bar/baz.wasm", + wantOutput: "baz", + }, + { + input: "foo.tar", + wantOutput: "foo", + }, + } { + t.Run(testcase.input, func(t *testing.T) { + output := compute.FileNameWithoutExtension(testcase.input) + testutil.AssertString(t, testcase.wantOutput, output) + }) + } +} + +func TestGetIgnoredFiles(t *testing.T) { + // we're going to chdir to a build environment, + // so save the pwd to return to, afterwards. + pwd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + + // Create test environment + rootdir := testutil.NewEnv(testutil.EnvOpts{ + T: t, + Copy: []testutil.FileIO{ + {Src: filepath.Join("testdata", "build", "rust", "Cargo.lock"), Dst: "Cargo.lock"}, + {Src: filepath.Join("testdata", "build", "rust", "Cargo.toml"), Dst: "Cargo.toml"}, + {Src: filepath.Join("testdata", "build", "rust", "src", "main.rs"), Dst: filepath.Join("src", "main.rs")}, + }, + }) + defer os.RemoveAll(rootdir) + + // before running the test, chdir into the build environment. + // when we're done, chdir back to our original location. + // this is so we can reliably copy the testdata/ fixtures. + if err := os.Chdir(rootdir); err != nil { + t.Fatal(err) + } + defer func() { + _ = os.Chdir(pwd) + }() + + for _, testcase := range []struct { + name string + fastlyignore string + wantfiles map[string]bool + }{ + { + name: "ignore src", + fastlyignore: "src/*", + wantfiles: map[string]bool{ + filepath.Join("src", "main.rs"): true, + }, + }, + { + name: "ignore cargo files", + fastlyignore: "Cargo.*", + wantfiles: map[string]bool{ + "Cargo.lock": true, + "Cargo.toml": true, + }, + }, + { + name: "ignore all", + fastlyignore: "*", + wantfiles: map[string]bool{ + ".fastlyignore": true, + "Cargo.lock": true, + "Cargo.toml": true, + "src": true, + }, + }, + } { + t.Run(testcase.name, func(t *testing.T) { + if err := os.WriteFile(filepath.Join(rootdir, compute.IgnoreFilePath), []byte(testcase.fastlyignore), 0o600); err != nil { + t.Fatal(err) + } + output, err := compute.GetIgnoredFiles(compute.IgnoreFilePath) + testutil.AssertNoError(t, err) + testutil.AssertEqual(t, testcase.wantfiles, output) + }) + } +} + +func TestGetNonIgnoredFiles(t *testing.T) { + // We're going to chdir to a build environment, + // so save the PWD to return to, afterwards. + pwd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + + // Create test environment + rootdir := testutil.NewEnv(testutil.EnvOpts{ + T: t, + Copy: []testutil.FileIO{ + {Src: filepath.Join("testdata", "build", "rust", "Cargo.lock"), Dst: "Cargo.lock"}, + {Src: filepath.Join("testdata", "build", "rust", "Cargo.toml"), Dst: "Cargo.toml"}, + {Src: filepath.Join("testdata", "build", "rust", "src", "main.rs"), Dst: filepath.Join("src", "main.rs")}, + }, + }) + defer os.RemoveAll(rootdir) + + // Before running the test, chdir into the build environment. + // When we're done, chdir back to our original location. + // This is so we can reliably copy the testdata/ fixtures. + if err := os.Chdir(rootdir); err != nil { + t.Fatal(err) + } + defer func() { + _ = os.Chdir(pwd) + }() + + for _, testcase := range []struct { + name string + path string + ignoredFiles map[string]bool + wantFiles []string + }{ + { + name: "no ignored files", + path: ".", + ignoredFiles: map[string]bool{}, + wantFiles: []string{ + "Cargo.lock", + "Cargo.toml", + filepath.Join("src", "main.rs"), + }, + }, + { + name: "one ignored file", + path: ".", + ignoredFiles: map[string]bool{ + filepath.Join("src", "main.rs"): true, + }, + wantFiles: []string{ + "Cargo.lock", + "Cargo.toml", + }, + }, + { + name: "multiple ignored files", + path: ".", + ignoredFiles: map[string]bool{ + "Cargo.toml": true, + "Cargo.lock": true, + }, + wantFiles: []string{ + filepath.Join("src", "main.rs"), + }, + }, + } { + t.Run(testcase.name, func(t *testing.T) { + output, err := compute.GetNonIgnoredFiles(testcase.path, testcase.ignoredFiles) + testutil.AssertNoError(t, err) + testutil.AssertEqual(t, testcase.wantFiles, output) + }) + } +} diff --git a/pkg/commands/compute/computeacl/computeacl_test.go b/pkg/commands/compute/computeacl/computeacl_test.go new file mode 100644 index 000000000..c481dbfcc --- /dev/null +++ b/pkg/commands/compute/computeacl/computeacl_test.go @@ -0,0 +1,631 @@ +package computeacl_test + +import ( + "bytes" + "fmt" + "io" + "net/http" + "strings" + "testing" + + root "github.com/fastly/cli/pkg/commands/compute" + sub "github.com/fastly/cli/pkg/commands/compute/computeacl" + fstfmt "github.com/fastly/cli/pkg/fmt" + "github.com/fastly/cli/pkg/testutil" + "github.com/fastly/go-fastly/v10/fastly/computeacls" +) + +func TestComputeACLCreate(t *testing.T) { + const ( + aclName = "foo" + aclID = "bar" + ) + + acl := computeacls.ComputeACL{ + Name: aclName, + ComputeACLID: aclID, + } + + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --name flag", + Args: "", + WantError: "error parsing arguments: required flag --name not provided", + }, + { + Name: "validate internal server error", + Args: fmt.Sprintf("--name %s", aclName), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusInternalServerError, + Status: http.StatusText(http.StatusInternalServerError), + }, + }, + }, + WantError: "500 - Internal Server Error", + }, + { + Name: "validate API success", + Args: fmt.Sprintf("--name %s", aclName), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(acl)))), + }, + }, + }, + WantOutput: fstfmt.Success("Created compute ACL '%s' (id: %s)", aclName, aclID), + }, + { + Name: "validate optional --json flag", + Args: fmt.Sprintf("--name %s --json", aclName), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(acl))), + }, + }, + }, + WantOutput: fstfmt.EncodeJSON(acl), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "create"}, scenarios) +} + +func TestComputeACLDelete(t *testing.T) { + const aclID = "foo" + + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --acl-id flag", + Args: "", + WantError: "error parsing arguments: required flag --acl-id not provided", + }, + { + Name: "validate bad request", + Args: "--acl-id bar", + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusBadRequest, + Status: http.StatusText(http.StatusBadRequest), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(` + { + "title": "invalid ACL ID", + "status": 400 + } + `))), + }, + }, + }, + WantError: "400 - Bad Request", + }, + { + Name: "validate API success", + Args: fmt.Sprintf("--acl-id %s", aclID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + }, + }, + }, + WantOutput: fstfmt.Success("Deleted compute ACL (id: %s)", aclID), + }, + { + Name: "validate optional --json flag", + Args: fmt.Sprintf("--acl-id %s --json", aclID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + }, + }, + }, + WantOutput: fstfmt.JSON(`{"id": %q, "deleted": true}`, aclID), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "delete"}, scenarios) +} + +func TestComputeACLDescribe(t *testing.T) { + const ( + aclName = "foo" + aclID = "bar" + ) + + acl := computeacls.ComputeACL{ + Name: aclName, + ComputeACLID: aclID, + } + + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --acl-id flag", + Args: "", + WantError: "error parsing arguments: required flag --acl-id not provided", + }, + { + Name: "validate bad request", + Args: "--acl-id baz", + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusBadRequest, + Status: http.StatusText(http.StatusBadRequest), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(` + { + "title": "invalid ACL ID", + "status": 400 + } + `))), + }, + }, + }, + WantError: "400 - Bad Request", + }, + { + Name: "validate API success", + Args: fmt.Sprintf("--acl-id %s", aclID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(acl)))), + }, + }, + }, + WantOutput: computeACL, + }, + { + Name: "validate optional --json flag", + Args: fmt.Sprintf("--acl-id %s --json", aclID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(acl)))), + }, + }, + }, + WantOutput: fstfmt.EncodeJSON(acl), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "describe"}, scenarios) +} + +func TestComputeACLList(t *testing.T) { + acls := computeacls.ComputeACLs{ + Data: []computeacls.ComputeACL{ + { + Name: "foo", + ComputeACLID: "bar", + }, + { + Name: "foobar", + ComputeACLID: "baz", + }, + }, + Meta: computeacls.MetaACLs{ + Total: 2, + }, + } + + scenarios := []testutil.CLIScenario{ + { + Name: "validate internal server error", + Args: "", + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusInternalServerError, + Status: http.StatusText(http.StatusInternalServerError), + }, + }, + }, + WantError: "500 - Internal Server Error", + }, + { + Name: "validate API success (zero compute ACLs)", + Args: "", + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(computeacls.ComputeACLs{ + Data: []computeacls.ComputeACL{}, + Meta: computeacls.MetaACLs{ + Total: 0, + }, + }))), + }, + }, + }, + WantOutput: zeroComputeACLs, + }, + { + Name: "validate API success", + Args: "", + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(acls))), + }, + }, + }, + WantOutput: computeACLs, + }, + { + Name: "validate optional --json flag", + Args: "--json", + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(acls))), + }, + }, + }, + WantOutput: fstfmt.EncodeJSON(acls), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "list-acls"}, scenarios) +} + +func TestComputeACLLookup(t *testing.T) { + const ( + aclID = "foo" + aclIP = "1.2.3.4" + ) + + entry := computeacls.ComputeACLEntry{ + Prefix: "1.2.3.4/32", + Action: "ALLOW", + } + + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --ip flag", + Args: fmt.Sprintf("--acl-id %s", aclID), + WantError: "error parsing arguments: required flag --ip not provided", + }, + { + Name: "validate missing --acl-id flag", + Args: fmt.Sprintf("--ip %s", aclIP), + WantError: "error parsing arguments: required flag --acl-id not provided", + }, + { + Name: "validate bad request", + Args: fmt.Sprintf("--acl-id baz --ip %s", aclIP), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusBadRequest, + Status: http.StatusText(http.StatusBadRequest), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(` + { + "title": "invalid ACL ID", + "status": 400 + } + `))), + }, + }, + }, + WantError: "400 - Bad Request", + }, + { + Name: "validate API status 204 (No Content)", + Args: fmt.Sprintf("--acl-id %s --ip 192.168.0.0", aclID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusNoContent, + Status: http.StatusText(http.StatusNoContent), + }, + }, + }, + WantOutput: fstfmt.Info("Compute ACL (%s) has no entry with IP (192.168.0.0)", aclID), + }, + { + Name: "validate API status 204 (No Content) with --json flag", + Args: fmt.Sprintf("--acl-id %s --ip 192.168.0.0 --json", aclID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusNoContent, + Status: http.StatusText(http.StatusNoContent), + }, + }, + }, + WantOutput: fstfmt.EncodeJSON(nil), + }, + { + Name: "validate API success", + Args: fmt.Sprintf("--acl-id %s --ip %s", aclID, aclIP), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(entry)))), + }, + }, + }, + WantOutput: computeACLEntry, + }, + { + Name: "validate optional --json flag", + Args: fmt.Sprintf("--acl-id %s --ip %s --json", aclID, aclIP), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(entry)))), + }, + }, + }, + WantOutput: fstfmt.EncodeJSON(&entry), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "lookup"}, scenarios) +} + +func TestComputeACLUpdate(t *testing.T) { + const aclID = "foo" + + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --acl-id flag", + Args: "--file testdata/batch.json", + WantError: "error parsing arguments: required flag --acl-id not provided", + }, + { + Name: "validate bad request", + Args: "--acl-id bar --file testdata/entries.json", + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusBadRequest, + Status: http.StatusText(http.StatusBadRequest), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(` + { + "title": "invalid ACL ID", + "status": 400 + } + `))), + }, + }, + }, + WantError: "400 - Bad Request", + }, + { + Name: "validate error from --file set with invalid json", + Args: fmt.Sprintf(`--acl-id %s --file {"foo":"bar"}`, aclID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusBadRequest, + Status: http.StatusText(http.StatusBadRequest), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(` + { + "title": "can't parse body", + "status": 400, + "detail": "missing field 'entries' at line 1 column 13" + } + `))), + }, + }, + }, + WantError: "missing 'entries' {\"foo\":\"bar\"}", + }, + { + Name: "validate error from --file set with zero json entries", + Args: fmt.Sprintf(`--acl-id %s --file {"entries":[]}`, aclID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusAccepted, + Status: http.StatusText(http.StatusAccepted), + }, + }, + }, + WantError: "missing 'entries' {\"entries\":[]}", + }, + { + Name: "validate success with --file", + Args: fmt.Sprintf("--acl-id %s --file testdata/entries.json", aclID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusAccepted, + Status: http.StatusText(http.StatusAccepted), + }, + }, + }, + WantOutput: fstfmt.Success("Updated %d compute ACL entries (id: %s)", 4, aclID), + }, + { + Name: "validate success with --file as inline json", + Args: fmt.Sprintf(`--acl-id %s --file {"entries":[{"op":"create","prefix":"1.2.3.0/24","action":"BLOCK"}]}`, aclID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusAccepted, + Status: http.StatusText(http.StatusAccepted), + }, + }, + }, + WantOutput: fstfmt.Success("Updated %d compute ACL entries (id: %s)", 1, aclID), + }, + { + Name: "validate success for updating a single entry with --operation, --prefix, and --action", + Args: fmt.Sprintf("--acl-id %s --operation create --prefix 1.2.3.0/24 --action BLOCK", aclID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusAccepted, + Status: http.StatusText(http.StatusAccepted), + }, + }, + }, + WantOutput: fstfmt.Success("Updated compute ACL entry (prefix: 1.2.3.0/24, id: %s)", aclID), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "update"}, scenarios) +} + +func TestComputeACLListEntries(t *testing.T) { + const aclID = "foo" + + entries := &computeacls.ComputeACLEntries{ + Entries: []computeacls.ComputeACLEntry{ + { + Prefix: "1.2.3.0/24", + Action: "BLOCK", + }, + { + Prefix: "1.2.3.4/32", + Action: "ALLOW", + }, + { + Prefix: "23.23.23.23/32", + Action: "ALLOW", + }, + { + Prefix: "192.168.0.0/16", + Action: "BLOCK", + }, + }, + Meta: computeacls.MetaEntries{ + Limit: 100, + }, + } + + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --acl-id flag", + Args: "", + WantError: "error parsing arguments: required flag --acl-id not provided", + }, + { + Name: "validate bad request", + Args: "--acl-id bar", + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusBadRequest, + Status: http.StatusText(http.StatusBadRequest), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(` + { + "title": "invalid ACL ID", + "status": 400 + } + `))), + }, + }, + }, + WantError: "400 - Bad Request", + }, + { + Name: "validate API success (zero compute ACL entries)", + Args: fmt.Sprintf("--acl-id %s", aclID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(computeacls.ComputeACLEntries{ + Entries: []computeacls.ComputeACLEntry{}, + Meta: computeacls.MetaEntries{ + Limit: 100, + }, + }))), + }, + }, + }, + WantOutput: zeroComputeACLEntries, + }, + { + Name: "validate API success", + Args: fmt.Sprintf("--acl-id %s", aclID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(entries))), + }, + }, + }, + WantOutput: computeACLEntries, + }, + { + Name: "validate optional --json flag", + Args: fmt.Sprintf("--acl-id %s --json", aclID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(entries))), + }, + }, + }, + WantOutput: fstfmt.EncodeJSON(entries.Entries), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "list-entries"}, scenarios) +} + +var computeACL = strings.TrimSpace(` +ID: bar +Name: foo +`) + "\n" + +var computeACLs = strings.TrimSpace(` +Name ID +foo bar +foobar baz +`) + "\n" + +var zeroComputeACLs = strings.TrimSpace(` +Name ID +`) + "\n" + +var computeACLEntry = strings.TrimSpace(` +Prefix: 1.2.3.4/32 +Action: ALLOW +`) + "\n" + +var computeACLEntries = strings.TrimSpace(` +Prefix Action +1.2.3.0/24 BLOCK +1.2.3.4/32 ALLOW +23.23.23.23/32 ALLOW +192.168.0.0/16 BLOCK +`) + "\n" + +var zeroComputeACLEntries = strings.TrimSpace(` +Prefix Action +`) + "\n" diff --git a/pkg/commands/compute/computeacl/create.go b/pkg/commands/compute/computeacl/create.go new file mode 100644 index 000000000..154860be5 --- /dev/null +++ b/pkg/commands/compute/computeacl/create.go @@ -0,0 +1,69 @@ +package computeacl + +import ( + "errors" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + "github.com/fastly/go-fastly/v10/fastly/computeacls" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// CreateCommand calls the Fastly API to create a compute ACL. +type CreateCommand struct { + argparser.Base + argparser.JSONOutput + + // Required. + name string +} + +// NewCreateCommand returns a usable command registered under the parent. +func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { + c := CreateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + + c.CmdClause = parent.Command("create", "Create a compute ACL") + + // Required. + c.CmdClause.Flag("name", "Name of the compute ACL").Required().StringVar(&c.name) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) + + return &c +} + +// Exec invokes the application logic for the command. +func (c *CreateCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + fc, ok := c.Globals.APIClient.(*fastly.Client) + if !ok { + return errors.New("failed to convert interface to a fastly client") + } + + acl, err := computeacls.Create(fc, &computeacls.CreateInput{ + Name: &c.name, + }) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if ok, err := c.WriteJSON(out, acl); ok { + return err + } + + text.Success(out, "Created compute ACL '%s' (id: %s)", acl.Name, acl.ComputeACLID) + return nil +} diff --git a/pkg/commands/compute/computeacl/delete.go b/pkg/commands/compute/computeacl/delete.go new file mode 100644 index 000000000..a5e25e62f --- /dev/null +++ b/pkg/commands/compute/computeacl/delete.go @@ -0,0 +1,77 @@ +package computeacl + +import ( + "errors" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + "github.com/fastly/go-fastly/v10/fastly/computeacls" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// DeleteCommand calls the Fastly API to delete a compute ACL. +type DeleteCommand struct { + argparser.Base + argparser.JSONOutput + + // Required. + id string +} + +// NewDeleteCommand returns a usable command registered under the parent. +func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { + c := DeleteCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + + c.CmdClause = parent.Command("delete", "Delete a compute ACL") + + // Required. + c.CmdClause.Flag("acl-id", "Compute ACL ID").Required().StringVar(&c.id) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) + + return &c +} + +// Exec invokes the application logic for the command. +func (c *DeleteCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + fc, ok := c.Globals.APIClient.(*fastly.Client) + if !ok { + return errors.New("failed to convert interface to a fastly client") + } + + err := computeacls.Delete(fc, &computeacls.DeleteInput{ + ComputeACLID: &c.id, + }) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if c.JSONOutput.Enabled { + o := struct { + ID string `json:"id"` + Deleted bool `json:"deleted"` + }{ + c.id, + true, + } + _, err := c.WriteJSON(out, o) + return err + } + + text.Success(out, "Deleted compute ACL (id: %s)", c.id) + return nil +} diff --git a/pkg/commands/compute/computeacl/describe.go b/pkg/commands/compute/computeacl/describe.go new file mode 100644 index 000000000..af9affa8d --- /dev/null +++ b/pkg/commands/compute/computeacl/describe.go @@ -0,0 +1,69 @@ +package computeacl + +import ( + "errors" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + "github.com/fastly/go-fastly/v10/fastly/computeacls" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// DescribeCommand calls the Fastly API to describe a compute ACL. +type DescribeCommand struct { + argparser.Base + argparser.JSONOutput + + // Required. + id string +} + +// NewDescribeCommand returns a usable command registered under the parent. +func NewDescribeCommand(parent argparser.Registerer, g *global.Data) *DescribeCommand { + c := DescribeCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + + c.CmdClause = parent.Command("describe", "Describe a compute ACL") + + // Required. + c.CmdClause.Flag("acl-id", "Compute ACL ID").Required().StringVar(&c.id) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) + + return &c +} + +// Exec invokes the application logic for the command. +func (c *DescribeCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + fc, ok := c.Globals.APIClient.(*fastly.Client) + if !ok { + return errors.New("failed to convert interface to a fastly client") + } + + acl, err := computeacls.Describe(fc, &computeacls.DescribeInput{ + ComputeACLID: &c.id, + }) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if ok, err := c.WriteJSON(out, acl); ok { + return err + } + + text.PrintComputeACL(out, "", acl) + return nil +} diff --git a/pkg/commands/compute/computeacl/doc.go b/pkg/commands/compute/computeacl/doc.go new file mode 100644 index 000000000..d1b77b35f --- /dev/null +++ b/pkg/commands/compute/computeacl/doc.go @@ -0,0 +1,2 @@ +// Package computeacl contains commands to inspect and manipulate Fastly compute ACLs. +package computeacl diff --git a/pkg/commands/compute/computeacl/listacls.go b/pkg/commands/compute/computeacl/listacls.go new file mode 100644 index 000000000..36d9dd9fb --- /dev/null +++ b/pkg/commands/compute/computeacl/listacls.go @@ -0,0 +1,60 @@ +package computeacl + +import ( + "errors" + "io" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" + "github.com/fastly/go-fastly/v10/fastly" + "github.com/fastly/go-fastly/v10/fastly/computeacls" +) + +// ListCommand calls the Fastly API to list all compute ACLs. +type ListCommand struct { + argparser.Base + argparser.JSONOutput +} + +// NewListCommand returns a usable command registered under the parent. +func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { + c := ListCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + + c.CmdClause = parent.Command("list-acls", "List all compute ACLs") + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) + + return &c +} + +// Exec invokes the application logic for the command. +func (c *ListCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + fc, ok := c.Globals.APIClient.(*fastly.Client) + if !ok { + return errors.New("failed to convert interface to a fastly client") + } + + acls, err := computeacls.ListACLs(fc) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if ok, err := c.WriteJSON(out, acls); ok { + return err + } + + text.PrintComputeACLsTbl(out, acls.Data) + return nil +} diff --git a/pkg/commands/compute/computeacl/listentries.go b/pkg/commands/compute/computeacl/listentries.go new file mode 100644 index 000000000..a86fed96c --- /dev/null +++ b/pkg/commands/compute/computeacl/listentries.go @@ -0,0 +1,117 @@ +package computeacl + +import ( + "errors" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + "github.com/fastly/go-fastly/v10/fastly/computeacls" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// ListEntriesCommand calls the Fastly API to list all entries of a compute ACLs. +type ListEntriesCommand struct { + argparser.Base + argparser.JSONOutput + + // Required. + id string + + // Optional. + cursor string + limit int +} + +// NewListEntriesCommand returns a usable command registered under the parent. +func NewListEntriesCommand(parent argparser.Registerer, g *global.Data) *ListEntriesCommand { + c := ListEntriesCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + + c.CmdClause = parent.Command("list-entries", "List all entries of a compute ACL") + + // Required. + c.CmdClause.Flag("acl-id", "Compute ACL ID").Required().StringVar(&c.id) + + // Optional. + c.RegisterFlag(argparser.CursorFlag(&c.cursor)) + c.RegisterFlagInt(argparser.LimitFlag(&c.limit)) + c.RegisterFlagBool(c.JSONFlag()) + + return &c +} + +// Exec invokes the application logic for the command. +func (c *ListEntriesCommand) Exec(in io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + fc, ok := c.Globals.APIClient.(*fastly.Client) + if !ok { + return errors.New("failed to convert interface to a fastly client") + } + + var entries []computeacls.ComputeACLEntry + loadAllPages := c.JSONOutput.Enabled || c.Globals.Flags.NonInteractive || c.Globals.Flags.AutoYes + + for { + o, err := computeacls.ListEntries(fc, &computeacls.ListEntriesInput{ + ComputeACLID: &c.id, + Cursor: &c.cursor, + Limit: &c.limit, + }) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if o != nil { + entries = append(entries, o.Entries...) + + if loadAllPages { + if next := o.Meta.NextCursor; next != "" { + c.cursor = next + continue + } + break + } + + text.PrintComputeACLEntriesTbl(out, o.Entries) + + if next := o.Meta.NextCursor; next != "" { + text.Break(out) + printNextPage, err := text.AskYesNo(out, "Print next page [y/N]: ", in) + if err != nil { + return err + } + if printNextPage { + c.cursor = next + continue + } + } + } + + break + } + + ok, err := c.WriteJSON(out, entries) + if err != nil { + return err + } + + // Only print output here if we've not already printed JSON. + // And only if we're non interactive. + // Otherwise interactive mode would have displayed each page of data. + if !ok && (c.Globals.Flags.NonInteractive || c.Globals.Flags.AutoYes) { + text.PrintComputeACLEntriesTbl(out, entries) + } + + return nil +} diff --git a/pkg/commands/compute/computeacl/lookup.go b/pkg/commands/compute/computeacl/lookup.go new file mode 100644 index 000000000..45293d348 --- /dev/null +++ b/pkg/commands/compute/computeacl/lookup.go @@ -0,0 +1,78 @@ +package computeacl + +import ( + "errors" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + "github.com/fastly/go-fastly/v10/fastly/computeacls" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// LookupCommand calls the Fastly API to lookup a compute ACL entry. +type LookupCommand struct { + argparser.Base + argparser.JSONOutput + + // Required. + id string + ip string +} + +// NewLookupCommand returns a usable command registered under the parent. +func NewLookupCommand(parent argparser.Registerer, g *global.Data) *LookupCommand { + c := LookupCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + + c.CmdClause = parent.Command("lookup", "Find a matching ACL entry for an IP address") + + // Required. + c.CmdClause.Flag("acl-id", "Compute ACL ID").Required().StringVar(&c.id) + c.CmdClause.Flag("ip", "Valid IPv4 or IPv6 address").Required().StringVar(&c.ip) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) + + return &c +} + +// Exec invokes the application logic for the command. +func (c *LookupCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + fc, ok := c.Globals.APIClient.(*fastly.Client) + if !ok { + return errors.New("failed to convert interface to a fastly client") + } + + entry, err := computeacls.Lookup(fc, &computeacls.LookupInput{ + ComputeACLID: &c.id, + ComputeACLIP: &c.ip, + }) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if ok, err := c.WriteJSON(out, entry); ok { + return err + } + + // Status 204 - No Content + if entry == nil { + text.Info(out, "Compute ACL (%s) has no entry with IP (%s)", c.id, c.ip) + return nil + } + + text.PrintComputeACLEntry(out, "", entry) + return nil +} diff --git a/pkg/commands/compute/computeacl/root.go b/pkg/commands/compute/computeacl/root.go new file mode 100644 index 000000000..e495fc786 --- /dev/null +++ b/pkg/commands/compute/computeacl/root.go @@ -0,0 +1,31 @@ +package computeacl + +import ( + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" +) + +// RootCommand is the parent command for all subcommands in this package. +// It should be installed under the primary root command. +type RootCommand struct { + argparser.Base + // no flags +} + +// CommandName is the string to be used to invoke this command. +const CommandName = "acl" + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent argparser.Registerer, g *global.Data) *RootCommand { + var c RootCommand + c.Globals = g + c.CmdClause = parent.Command(CommandName, "Manipulate Fastly compute ACLs") + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { + panic("unreachable") +} diff --git a/pkg/commands/compute/computeacl/testdata/entries.json b/pkg/commands/compute/computeacl/testdata/entries.json new file mode 100644 index 000000000..0c202b9be --- /dev/null +++ b/pkg/commands/compute/computeacl/testdata/entries.json @@ -0,0 +1,24 @@ +{ + "entries": [ + { + "op": "create", + "prefix": "1.2.3.0/24", + "action": "BLOCK" + }, + { + "op": "update", + "prefix": "192.168.0.0/16", + "action": "BLOCK" + }, + { + "op": "create", + "prefix": "23.23.23.23/32", + "action": "ALLOW" + }, + { + "op": "update", + "prefix": "1.2.3.4/32", + "action": "ALLOW" + } + ] +} diff --git a/pkg/commands/compute/computeacl/update.go b/pkg/commands/compute/computeacl/update.go new file mode 100644 index 000000000..5e64949e1 --- /dev/null +++ b/pkg/commands/compute/computeacl/update.go @@ -0,0 +1,147 @@ +package computeacl + +import ( + "encoding/json" + "errors" + "fmt" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + "github.com/fastly/go-fastly/v10/fastly/computeacls" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// UpdateCommand calls the Fastly API to update a compute ACL. +type UpdateCommand struct { + argparser.Base + + // Required. + computeACLID string + + // Optional. + file argparser.OptionalString + operation argparser.OptionalString + prefix argparser.OptionalString + action argparser.OptionalString +} + +// operations is a list of supported operation options. +var operations = []string{"create", "update"} + +// actions is a list of supported action options. +var actions = []string{"BLOCK", "ALLOW"} + +// NewUpdateCommand returns a usable command registered under the parent. +func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { + c := UpdateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + + c.CmdClause = parent.Command("update", "Update a compute ACL") + + // Required. + c.CmdClause.Flag("acl-id", "Alphanumeric string identifying a compute ACL").Required().StringVar(&c.computeACLID) + + // Optional. + c.CmdClause.Flag("file", "Batch update JSON file passed as file path or content, e.g. $(< batch.json)").Action(c.file.Set).StringVar(&c.file.Value) + c.CmdClause.Flag("operation", "Indicating that this entry is to be added to/updated in the ACL").HintOptions(operations...).EnumVar(&c.operation.Value, operations...) + c.CmdClause.Flag("prefix", "An IP prefix defined in Classless Inter-Domain Routing (CIDR) format, i.e. a valid IP address (v4 or v6) followed by a forward slash (/) and a prefix length (0-32 or 0-128, depending on address family)").Action(c.prefix.Set).StringVar(&c.prefix.Value) + c.CmdClause.Flag("action", "The action taken on the IP address").HintOptions(actions...).EnumVar(&c.action.Value, actions...) + + return &c +} + +// Exec invokes the application logic for the command. +func (c *UpdateCommand) Exec(_ io.Reader, out io.Writer) error { + fc, ok := c.Globals.APIClient.(*fastly.Client) + if !ok { + return errors.New("failed to convert interface to a fastly client") + } + + if c.file.WasSet { + input, err := c.constructBatchInput() + if err != nil { + return err + } + + err = computeacls.Update(fc, input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + text.Success(out, "Updated %d compute ACL entries (id: %s)", len(input.Entries), c.computeACLID) + return nil + } + + input, err := c.constructInput() + if err != nil { + return err + } + + err = computeacls.Update(fc, input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + text.Success(out, "Updated compute ACL entry (prefix: %s, id: %s)", c.prefix.Value, c.computeACLID) + return nil +} + +// constructBatchInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *UpdateCommand) constructBatchInput() (*computeacls.UpdateInput, error) { + var input computeacls.UpdateInput + + input.ComputeACLID = &c.computeACLID + + s := argparser.Content(c.file.Value) + bs := []byte(s) + + err := json.Unmarshal(bs, &input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "File": s, + }) + return nil, err + } + + if len(input.Entries) == 0 { + err := fsterr.RemediationError{ + Inner: fmt.Errorf("missing 'entries' %s", c.file.Value), + Remediation: "Consult the API documentation for the JSON format: https://www.fastly.com/documentation/reference/api/acls/acls/#compute-acl-update-acls", + } + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "File": string(bs), + }) + return nil, err + } + + return &input, nil +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *UpdateCommand) constructInput() (*computeacls.UpdateInput, error) { + var input computeacls.UpdateInput + + if c.operation.Value == "" || c.prefix.Value == "" || c.action.Value == "" { + return nil, fsterr.ErrInvalidComputeACLCombo + } + + input.ComputeACLID = &c.computeACLID + input.Entries = []*computeacls.BatchComputeACLEntry{ + { + Prefix: &c.prefix.Value, + Action: &c.action.Value, + Operation: &c.operation.Value, + }, + } + + return &input, nil +} diff --git a/pkg/commands/compute/deploy.go b/pkg/commands/compute/deploy.go new file mode 100644 index 000000000..bf395afc1 --- /dev/null +++ b/pkg/commands/compute/deploy.go @@ -0,0 +1,1295 @@ +package compute + +import ( + "errors" + "fmt" + "io" + "io/fs" + "net/http" + "os" + "os/signal" + "path/filepath" + "strconv" + "strings" + "syscall" + "time" + + "github.com/kennygrant/sanitize" + "github.com/mholt/archiver/v3" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/api" + "github.com/fastly/cli/pkg/api/undocumented" + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/compute/setup" + "github.com/fastly/cli/pkg/debug" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/internal/beacon" + "github.com/fastly/cli/pkg/lookup" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/text" + "github.com/fastly/cli/pkg/undo" +) + +const ( + manageServiceBaseURL = "https://manage.fastly.com/configure/services/" + trialNotActivated = "Valid values for 'type' are: 'vcl'" +) + +// ErrPackageUnchanged is an error that indicates the package hasn't changed. +var ErrPackageUnchanged = errors.New("package is unchanged") + +// DeployCommand deploys an artifact previously produced by build. +type DeployCommand struct { + argparser.Base + manifestPath string + + // NOTE: these are public so that the "publish" composite command can set the + // values appropriately before calling the Exec() function. + Comment argparser.OptionalString + Dir string + Domain string + Env string + PackagePath string + ServiceName argparser.OptionalServiceNameID + ServiceVersion argparser.OptionalServiceVersion + StatusCheckCode int + StatusCheckOff bool + StatusCheckPath string + StatusCheckTimeout int + SkipChangeDir bool // set by parent composite commands (e.g. serve, publish) +} + +// NewDeployCommand returns a usable command registered under the parent. +func NewDeployCommand(parent argparser.Registerer, g *global.Data) *DeployCommand { + var c DeployCommand + c.Globals = g + c.CmdClause = parent.Command("deploy", "Deploy a package to a Fastly Compute service") + + // NOTE: when updating these flags, be sure to update the composite command: + // `compute publish`. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &c.Globals.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.ServiceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.ServiceName.Value, + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.ServiceVersion.Set, + Description: argparser.FlagVersionDesc, + Dst: &c.ServiceVersion.Value, + Name: argparser.FlagVersionName, + }) + c.CmdClause.Flag("comment", "Human-readable comment").Action(c.Comment.Set).StringVar(&c.Comment.Value) + c.CmdClause.Flag("dir", "Project directory (default: current directory)").Short('C').StringVar(&c.Dir) + c.CmdClause.Flag("domain", "The name of the domain associated to the package").StringVar(&c.Domain) + c.CmdClause.Flag("env", "The manifest environment config to use (e.g. 'stage' will attempt to read 'fastly.stage.toml')").StringVar(&c.Env) + c.CmdClause.Flag("package", "Path to a package tar.gz").Short('p').StringVar(&c.PackagePath) + c.CmdClause.Flag("status-check-code", "Set the expected status response for the service availability check").IntVar(&c.StatusCheckCode) + c.CmdClause.Flag("status-check-off", "Disable the service availability check").BoolVar(&c.StatusCheckOff) + c.CmdClause.Flag("status-check-path", "Specify the URL path for the service availability check").Default("/").StringVar(&c.StatusCheckPath) + c.CmdClause.Flag("status-check-timeout", "Set a timeout (in seconds) for the service availability check").Default("120").IntVar(&c.StatusCheckTimeout) + return &c +} + +// Exec implements the command interface. +func (c *DeployCommand) Exec(in io.Reader, out io.Writer) (err error) { + manifestFilename := EnvironmentManifest(c.Env) + if c.Env != "" { + if c.Globals.Verbose() { + text.Info(out, EnvManifestMsg, manifestFilename, manifest.Filename) + } + } + wd, err := os.Getwd() + if err != nil { + return fmt.Errorf("failed to get current working directory: %w", err) + } + defer func() { + _ = os.Chdir(wd) + }() + c.manifestPath = filepath.Join(wd, manifestFilename) + + var projectDir string + if !c.SkipChangeDir { + projectDir, err = ChangeProjectDirectory(c.Dir) + if err != nil { + return err + } + if projectDir != "" { + if c.Globals.Verbose() { + text.Info(out, ProjectDirMsg, projectDir) + } + c.manifestPath = filepath.Join(projectDir, manifestFilename) + } + } + + spinner, err := text.NewSpinner(out) + if err != nil { + return err + } + + err = spinner.Process(fmt.Sprintf("Verifying %s", manifestFilename), func(_ *text.SpinnerWrapper) error { + // The check for c.SkipChangeDir here is because we might need to attempt + // another read of the manifest file. To explain: if we're skipping the + // change of directory, it means we were called from a composite command, + // which has already changed directory to one that contains the fastly.toml + // file. This means we should try reading the manifest file from the new + // location as the potential ReadError() would have been based on the + // initial directory the CLI was invoked from. + if c.SkipChangeDir || projectDir != "" || c.Env != "" { + err = c.Globals.Manifest.File.Read(c.manifestPath) + } else { + err = c.Globals.Manifest.File.ReadError() + } + if err != nil { + // If the user hasn't specified a package to deploy, then we'll just check + // the read error and return it. + if c.PackagePath == "" { + if errors.Is(err, os.ErrNotExist) { + err = fsterr.ErrReadingManifest + } + c.Globals.ErrLog.Add(err) + return err + } + // Otherwise, we'll attempt to read the manifest from within the given + // package archive. + if err := readManifestFromPackageArchive(c.Globals.Manifest, c.PackagePath, manifestFilename); err != nil { + return err + } + if c.Globals.Verbose() { + text.Info(out, "Using %s within --package archive: %s\n\n", manifestFilename, c.PackagePath) + } + } + return nil + }) + if err != nil { + return err + } + text.Break(out) + + fnActivateTrial, serviceID, err := c.Setup(out) + if err != nil { + return err + } + noExistingService := serviceID == "" + + undoStack := undo.NewStack() + undoStack.Push(func() error { + if noExistingService && serviceID != "" { + return c.CleanupNewService(serviceID, manifestFilename, out) + } + return nil + }) + + defer func(errLog fsterr.LogInterface) { + if err != nil { + errLog.Add(err) + } + undoStack.RunIfError(out, err) + }(c.Globals.ErrLog) + + signalCh := make(chan os.Signal, 1) + signal.Notify(signalCh, syscall.SIGINT, syscall.SIGTERM) + go monitorSignals(signalCh, noExistingService, out, undoStack, spinner) + + var serviceVersion *fastly.Version + if noExistingService { + serviceID, serviceVersion, err = c.NewService(manifestFilename, fnActivateTrial, spinner, in, out) + if err != nil { + return err + } + if serviceID == "" { + return nil // user declined service creation prompt + } + } else { + // ErrPackageUnchanged is returned AFTER identifying the service version. + // nosemgrep: trailofbits.go.invalid-usage-of-modified-variable.invalid-usage-of-modified-variable + serviceVersion, err = c.ExistingServiceVersion(serviceID, out) + if err != nil { + if errors.Is(err, ErrPackageUnchanged) { + text.Info(out, "Skipping package deployment, local and service version are identical. (service %s, version %d) ", serviceID, fastly.ToValue(serviceVersion.Number)) + return nil + } + return err + } + if c.Globals.Manifest.File.Setup.Defined() && !c.Globals.Flags.Quiet { + text.Info(out, "\nProcessing of the %s [setup] configuration happens only for a new service. Once a service is created, any further changes to the service or its resources must be made manually.\n\n", manifestFilename) + } + } + + var sr ServiceResources + serviceVersionNumber := fastly.ToValue(serviceVersion.Number) + + // NOTE: A 'domain' resource isn't strictly part of the [setup] config. + // It's part of the implementation so that we can utilise the same interface. + // A domain is required regardless of whether it's a new service or existing. + sr.domains = &setup.Domains{ + APIClient: c.Globals.APIClient, + AcceptDefaults: c.Globals.Flags.AcceptDefaults, + NonInteractive: c.Globals.Flags.NonInteractive, + PackageDomain: c.Domain, + RetryLimit: 5, + ServiceID: serviceID, + ServiceVersion: serviceVersionNumber, + Stdin: in, + Stdout: out, + Verbose: c.Globals.Verbose(), + } + if err = sr.domains.Validate(); err != nil { + errLogService(c.Globals.ErrLog, err, serviceID, serviceVersionNumber) + return fmt.Errorf("error configuring service domains: %w", err) + } + if noExistingService { + c.ConstructNewServiceResources( + &sr, serviceID, serviceVersionNumber, in, out, + ) + } + + if sr.domains.Missing() { + if err := sr.domains.Configure(); err != nil { + errLogService(c.Globals.ErrLog, err, serviceID, serviceVersionNumber) + return fmt.Errorf("error configuring service domains: %w", err) + } + } + if noExistingService { + if err = c.ConfigureServiceResources(sr, serviceID, serviceVersionNumber); err != nil { + return err + } + } + + if sr.domains.Missing() { + sr.domains.Spinner = spinner + if err := sr.domains.Create(); err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Accept defaults": c.Globals.Flags.AcceptDefaults, + "Auto-yes": c.Globals.Flags.AutoYes, + "Non-interactive": c.Globals.Flags.NonInteractive, + "Service ID": serviceID, + "Service Version": serviceVersion, + }) + return err + } + } + if noExistingService { + if err = c.CreateServiceResources(sr, spinner, serviceID, serviceVersionNumber); err != nil { + return err + } + } + + err = c.UploadPackage(spinner, serviceID, serviceVersionNumber) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Package path": c.PackagePath, + "Service ID": serviceID, + "Service Version": serviceVersion, + }) + return err + } + + if err = c.ProcessService(serviceID, serviceVersionNumber, spinner); err != nil { + return err + } + + serviceURL, err := c.GetServiceURL(serviceID, serviceVersionNumber) + if err != nil { + return err + } + + if !c.StatusCheckOff && noExistingService { + c.StatusCheck(serviceURL, spinner, out) + } + + if !noExistingService { + text.Break(out) + } + displayDeployOutput(out, manageServiceBaseURL, serviceID, serviceURL, serviceVersionNumber) + return nil +} + +// StatusCheck checks the service URL and identifies when it's ready. +func (c *DeployCommand) StatusCheck(serviceURL string, spinner text.Spinner, out io.Writer) { + var ( + err error + status int + ) + if status, err = checkingServiceAvailability(serviceURL+c.StatusCheckPath, spinner, c); err != nil { + if re, ok := err.(fsterr.RemediationError); ok { + text.Warning(out, re.Remediation) + } + } + + // Because the service availability can return an error (which we ignore), + // then we need to check for the 'no error' scenarios. + if err == nil { + switch { + case validStatusCodeRange(c.StatusCheckCode) && status != c.StatusCheckCode: + // If the user set a specific status code expectation... + text.Warning(out, "The service path `%s` responded with a status code (%d) that didn't match what was expected (%d).", c.StatusCheckPath, status, c.StatusCheckCode) + case !validStatusCodeRange(c.StatusCheckCode) && status >= http.StatusBadRequest: + // If no status code was specified, and the actual status response was an error... + text.Info(out, "The service path `%s` responded with a non-successful status code (%d). Please check your application code if this is an unexpected response.", c.StatusCheckPath, status) + default: + text.Break(out) + } + } +} + +func displayDeployOutput(out io.Writer, manageServiceBaseURL, serviceID, serviceURL string, serviceVersion int) { + text.Description(out, "Manage this service at", fmt.Sprintf("%s%s", manageServiceBaseURL, serviceID)) + text.Description(out, "View this service at", serviceURL) + text.Success(out, "Deployed package (service %s, version %v)", serviceID, serviceVersion) +} + +// validStatusCodeRange checks the status is a valid status code. +// e.g. >= 100 and <= 999. +func validStatusCodeRange(status int) bool { + if status >= 100 && status <= 999 { + return true + } + return false +} + +// Setup prepares the environment. +// +// - Check if there is an API token missing. +// - Acquire the Service ID/Version. +// - Validate there is a package to deploy. +// - Determine if a trial needs to be activated on the user's account. +func (c *DeployCommand) Setup(out io.Writer) (fnActivateTrial Activator, serviceID string, err error) { + defaultActivator := func(_ string) error { return nil } + + token, s := c.Globals.Token() + if s == lookup.SourceUndefined { + return defaultActivator, "", fsterr.ErrNoToken + } + + // IMPORTANT: We don't handle the error when looking up the Service ID. + // This is because later in the Exec() flow we might create a 'new' service. + serviceID, source, flag, err := argparser.ServiceID(c.ServiceName, *c.Globals.Manifest, c.Globals.APIClient, c.Globals.ErrLog) + if err == nil && c.Globals.Verbose() { + argparser.DisplayServiceID(serviceID, flag, source, out) + } + + if c.PackagePath == "" { + projectName, source := c.Globals.Manifest.Name() + if source == manifest.SourceUndefined { + return defaultActivator, serviceID, fsterr.ErrReadingManifest + } + c.PackagePath = filepath.Join("pkg", fmt.Sprintf("%s.tar.gz", sanitize.BaseName(projectName))) + } + + err = validatePackage(c.PackagePath) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Package path": c.PackagePath, + }) + return defaultActivator, serviceID, err + } + + endpoint, _ := c.Globals.APIEndpoint() + fnActivateTrial = preconfigureActivateTrial(endpoint, token, c.Globals.HTTPClient, c.Globals.Env.DebugMode) + + return fnActivateTrial, serviceID, err +} + +// validatePackage checks the package and returns its path, which can change +// depending on the user flow scenario. +func validatePackage(pkgPath string) error { + pkgSize, err := packageSize(pkgPath) + if err != nil { + return fsterr.RemediationError{ + Inner: fmt.Errorf("error reading package size: %w", err), + Remediation: "Run `fastly compute build` to produce a Compute package, alternatively use the --package flag to reference a package outside of the current project.", + } + } + if pkgSize > MaxPackageSize { + return fsterr.RemediationError{ + Inner: fmt.Errorf("package size is too large (%d bytes)", pkgSize), + Remediation: fsterr.PackageSizeRemediation, + } + } + return validatePackageContent(pkgPath) +} + +// readManifestFromPackageArchive extracts the manifest file from the given +// package archive file and reads it into memory. +func readManifestFromPackageArchive(data *manifest.Data, packageFlag, manifestFilename string) error { + dst, err := os.MkdirTemp("", fmt.Sprintf("%s-*", manifestFilename)) + if err != nil { + return err + } + defer os.RemoveAll(dst) + + if err = archiver.Unarchive(packageFlag, dst); err != nil { + return fmt.Errorf("error extracting package '%s': %w", packageFlag, err) + } + + files, err := os.ReadDir(dst) + if err != nil { + return err + } + extractedDirName := files[0].Name() + + manifestPath, err := locateManifest(filepath.Join(dst, extractedDirName), manifestFilename) + if err != nil { + return err + } + + err = data.File.Read(manifestPath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + err = fsterr.ErrReadingManifest + } + return err + } + + return nil +} + +// locateManifest attempts to find the manifest within the given path's +// directory tree. +func locateManifest(path, manifestFilename string) (string, error) { + root, err := filepath.Abs(path) + if err != nil { + return "", err + } + + var foundManifest string + + err = filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + if !entry.IsDir() && filepath.Base(path) == manifestFilename { + foundManifest = path + return fsterr.ErrStopWalk + } + return nil + }) + if err != nil { + // If the error isn't ErrStopWalk, then the WalkDir() function had an + // issue processing the directory tree. + if err != fsterr.ErrStopWalk { + return "", err + } + + return foundManifest, nil + } + + return "", fmt.Errorf("error locating manifest within the given path: %s", path) +} + +// packageSize returns the size of the .tar.gz package. +// +// Reference: +// https://docs.fastly.com/products/compute-at-edge-billing-and-resource-limits#resource-limits +func packageSize(path string) (size int64, err error) { + fi, err := os.Stat(path) + if err != nil { + return size, err + } + return fi.Size(), nil +} + +// Activator represents a function that calls an undocumented API endpoint for +// activating a Compute free trial on the given customer account. +// +// It is preconfigured with the Fastly API endpoint, a user token and a simple +// HTTP Client. +// +// This design allows us to pass an Activator rather than passing multiple +// unrelated arguments through several nested functions. +type Activator func(customerID string) error + +// preconfigureActivateTrial activates a free trial on the customer account. +func preconfigureActivateTrial(endpoint, token string, httpClient api.HTTPClient, debugMode string) Activator { + debug, _ := strconv.ParseBool(debugMode) + return func(customerID string) error { + _, err := undocumented.Call(undocumented.CallOptions{ + APIEndpoint: endpoint, + HTTPClient: httpClient, + Method: http.MethodPost, + Path: fmt.Sprintf(undocumented.EdgeComputeTrial, customerID), + Token: token, + Debug: debug, + }) + if err != nil { + apiErr, ok := err.(undocumented.APIError) + if !ok { + return err + } + // 409 Conflict == The Compute trial has already been created. + if apiErr.StatusCode != http.StatusConflict { + return fmt.Errorf("%w: %d %s", err, apiErr.StatusCode, http.StatusText(apiErr.StatusCode)) + } + } + return nil + } +} + +// NewService handles creating a new service when no Service ID is found. +func (c *DeployCommand) NewService(manifestFilename string, fnActivateTrial Activator, spinner text.Spinner, in io.Reader, out io.Writer) (string, *fastly.Version, error) { + var ( + err error + serviceID string + serviceVersion *fastly.Version + ) + + if !c.Globals.Flags.AutoYes && !c.Globals.Flags.NonInteractive { + text.Output(out, "There is no Fastly service associated with this package. To connect to an existing service add the Service ID to the %s file, otherwise follow the prompts to create a service now.\n\n", manifestFilename) + text.Output(out, "Press ^C at any time to quit.") + + if c.Globals.Manifest.File.Setup.Defined() { + text.Info(out, "\nProcessing of the %s [setup] configuration happens only when there is no existing service. Once a service is created, any further changes to the service or its resources must be made manually.", manifestFilename) + } + + text.Break(out) + answer, err := text.AskYesNo(out, "Create new service: [y/N] ", in) + if err != nil { + return serviceID, serviceVersion, err + } + if !answer { + return serviceID, serviceVersion, nil + } + text.Break(out) + } + + defaultServiceName := c.Globals.Manifest.File.Name + var serviceName string + + // The service name will be whatever is set in the --service-name flag. + // If the flag isn't set, and we're non-interactive, we'll use the default. + // If the flag isn't set, and we're interactive, we'll prompt the user. + switch { + case c.ServiceName.WasSet: + serviceName = c.ServiceName.Value + case c.Globals.Flags.AcceptDefaults || c.Globals.Flags.NonInteractive: + serviceName = defaultServiceName + default: + serviceName, err = text.Input(out, text.Prompt(fmt.Sprintf("Service name: [%s] ", defaultServiceName)), in) + if err != nil || serviceName == "" { + serviceName = defaultServiceName + } + } + + // There is no service and so we'll do a one time creation of the service + // + // NOTE: we're shadowing the `serviceID` and `serviceVersion` variables. + serviceID, serviceVersion, err = createService(c.Globals, serviceName, fnActivateTrial, spinner, out) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service name": serviceName, + }) + return serviceID, serviceVersion, err + } + + err = c.UpdateManifestServiceID(serviceID, c.manifestPath) + + // NOTE: Skip error if --package flag is set. + // + // This is because the use of the --package flag suggests the user is not + // within a project directory. If that is the case, then we don't want the + // error to be returned because of course there is no manifest to update. + // + // If the user does happen to be in a project directory and they use the + // --package flag, then the above function call to update the manifest will + // have succeeded and so there will be no error. + if err != nil && c.PackagePath == "" { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + }) + return serviceID, serviceVersion, err + } + + return serviceID, serviceVersion, nil +} + +// createService creates a service to associate with the compute package. +// +// NOTE: If the creation of the service fails because the user has not +// activated a free trial, then we'll trigger the trial for their account. +func createService( + g *global.Data, + serviceName string, + fnActivateTrial Activator, + spinner text.Spinner, + out io.Writer, +) (serviceID string, serviceVersion *fastly.Version, err error) { + f := g.Flags + apiClient := g.APIClient + errLog := g.ErrLog + + if !f.AcceptDefaults && !f.NonInteractive { + text.Break(out) + } + + err = spinner.Start() + if err != nil { + return "", nil, err + } + msg := "Creating service" + spinner.Message(msg + "...") + + service, err := apiClient.CreateService(&fastly.CreateServiceInput{ + Name: &serviceName, + Type: fastly.ToPointer("wasm"), + }) + if err != nil { + if strings.Contains(err.Error(), trialNotActivated) { + user, err := apiClient.GetCurrentUser() + if err != nil { + err = fmt.Errorf("unable to identify user associated with the given token: %w", err) + spinner.StopFailMessage(msg) + spinErr := spinner.StopFail() + if spinErr != nil { + return "", nil, fmt.Errorf(text.SpinnerErrWrapper, spinErr, err) + } + return serviceID, serviceVersion, fsterr.RemediationError{ + Inner: err, + Remediation: "To ensure you have access to the Compute platform we need your Customer ID. " + fsterr.AuthRemediation, + } + } + + customerID := fastly.ToValue(user.CustomerID) + err = fnActivateTrial(customerID) + if err != nil { + err = fmt.Errorf("error creating service: you do not have the Compute free trial enabled on your Fastly account") + spinner.StopFailMessage(msg) + spinErr := spinner.StopFail() + if spinErr != nil { + return "", nil, fmt.Errorf(text.SpinnerErrWrapper, spinErr, err) + } + return serviceID, serviceVersion, fsterr.RemediationError{ + Inner: err, + Remediation: fsterr.ComputeTrialRemediation, + } + } + + errLog.AddWithContext(err, map[string]any{ + "Service Name": serviceName, + "Customer ID": customerID, + }) + + spinner.StopFailMessage(msg) + err = spinner.StopFail() + if err != nil { + return "", nil, err + } + + return createService(g, serviceName, fnActivateTrial, spinner, out) + } + + spinner.StopFailMessage(msg) + spinErr := spinner.StopFail() + if spinErr != nil { + return "", nil, spinErr + } + + errLog.AddWithContext(err, map[string]any{ + "Service Name": serviceName, + }) + return serviceID, serviceVersion, fmt.Errorf("error creating service: %w", err) + } + + spinner.StopMessage(msg) + err = spinner.Stop() + if err != nil { + return "", nil, err + } + return fastly.ToValue(service.ServiceID), &fastly.Version{Number: fastly.ToPointer(1)}, nil +} + +// CleanupNewService is executed if a new service flow has errors. +// It deletes the service, which will cause any contained resources to be deleted. +// It will also strip the Service ID from the fastly.toml manifest file. +func (c *DeployCommand) CleanupNewService(serviceID, manifestFilename string, out io.Writer) error { + text.Info(out, "\nCleaning up service\n\n") + err := c.Globals.APIClient.DeleteService(&fastly.DeleteServiceInput{ + ServiceID: serviceID, + }) + if err != nil { + return err + } + + text.Info(out, "Removing Service ID from %s\n\n", manifestFilename) + err = c.UpdateManifestServiceID("", c.manifestPath) + if err != nil { + return err + } + + text.Output(out, "Cleanup complete") + return nil +} + +// UpdateManifestServiceID updates the Service ID in the manifest. +// +// There are two scenarios where this function is called. The first is when we +// have a Service ID to insert into the manifest. The other is when there is an +// error in the deploy flow, and for which the Service ID will be set to an +// empty string (otherwise the service itself will be deleted while the +// manifest will continue to hold a reference to it). +func (c *DeployCommand) UpdateManifestServiceID(serviceID, manifestPath string) error { + if err := c.Globals.Manifest.File.Read(manifestPath); err != nil { + return fmt.Errorf("error reading %s: %w", manifestPath, err) + } + c.Globals.Manifest.File.ServiceID = serviceID + if err := c.Globals.Manifest.File.Write(manifestPath); err != nil { + return fmt.Errorf("error saving %s: %w", manifestPath, err) + } + return nil +} + +// errLogService records the error, service id and version into the error log. +func errLogService(l fsterr.LogInterface, err error, sid string, sv int) { + l.AddWithContext(err, map[string]any{ + "Service ID": sid, + "Service Version": sv, + }) +} + +// CompareLocalRemotePackage compares the local package files hash against the +// existing service package version and exits early with message if identical. +// +// NOTE: We can't avoid the first 'no-changes' upload after the initial deploy. +// This is because the fastly.toml manifest does actual change after first deploy. +// When user first deploys, there is no value for service_id. +// That version of the manifest is inside the package we're checking against. +// So on the second deploy, even if user has made no changes themselves, we will +// still upload that package because technically there was a change made by the +// CLI to add the Service ID. Any subsequent deploys will be aborted because +// there will be no changes made by the CLI nor the user. +func (c *DeployCommand) CompareLocalRemotePackage(serviceID string, version int) error { + filesHash, err := getFilesHash(c.PackagePath) + if err != nil { + return err + } + p, err := c.Globals.APIClient.GetPackage(&fastly.GetPackageInput{ + ServiceID: serviceID, + ServiceVersion: version, + }) + // IMPORTANT: Skip error as some services won't have a package to compare. + // This happens in situations where a user will create the service outside of + // the CLI and then reference the Service ID in their fastly.toml manifest. + // In that scenario the service might just be an empty service and so trying + // to get the package from the service with 404. + if err == nil && p.Metadata != nil && filesHash == fastly.ToValue(p.Metadata.FilesHash) { + return ErrPackageUnchanged + } + return nil +} + +// UploadPackage uploads the package to the specified service and version. +func (c *DeployCommand) UploadPackage(spinner text.Spinner, serviceID string, version int) error { + return spinner.Process("Uploading package", func(_ *text.SpinnerWrapper) error { + _, err := c.Globals.APIClient.UpdatePackage(&fastly.UpdatePackageInput{ + ServiceID: serviceID, + ServiceVersion: version, + PackagePath: fastly.ToPointer(c.PackagePath), + }) + if err != nil { + return fmt.Errorf("error uploading package: %w", err) + } + return nil + }) +} + +// ServiceResources is a collection of backend objects created during setup. +// Objects may be nil. +type ServiceResources struct { + domains *setup.Domains + backends *setup.Backends + configStores *setup.ConfigStores + loggers *setup.Loggers + objectStores *setup.KVStores + kvStores *setup.KVStores + secretStores *setup.SecretStores +} + +// ConstructNewServiceResources instantiates multiple [setup] config resources for a +// new Service to process. +func (c *DeployCommand) ConstructNewServiceResources( + sr *ServiceResources, + serviceID string, + serviceVersion int, + in io.Reader, + out io.Writer, +) { + sr.backends = &setup.Backends{ + APIClient: c.Globals.APIClient, + AcceptDefaults: c.Globals.Flags.AcceptDefaults, + NonInteractive: c.Globals.Flags.NonInteractive, + ServiceID: serviceID, + ServiceVersion: serviceVersion, + Setup: c.Globals.Manifest.File.Setup.Backends, + Stdin: in, + Stdout: out, + } + + sr.configStores = &setup.ConfigStores{ + APIClient: c.Globals.APIClient, + AcceptDefaults: c.Globals.Flags.AcceptDefaults, + NonInteractive: c.Globals.Flags.NonInteractive, + ServiceID: serviceID, + ServiceVersion: serviceVersion, + Setup: c.Globals.Manifest.File.Setup.ConfigStores, + Stdin: in, + Stdout: out, + } + + sr.loggers = &setup.Loggers{ + Setup: c.Globals.Manifest.File.Setup.Loggers, + Stdout: out, + } + + sr.objectStores = &setup.KVStores{ + APIClient: c.Globals.APIClient, + AcceptDefaults: c.Globals.Flags.AcceptDefaults, + NonInteractive: c.Globals.Flags.NonInteractive, + ServiceID: serviceID, + ServiceVersion: serviceVersion, + Setup: c.Globals.Manifest.File.Setup.ObjectStores, + Stdin: in, + Stdout: out, + } + + sr.kvStores = &setup.KVStores{ + APIClient: c.Globals.APIClient, + AcceptDefaults: c.Globals.Flags.AcceptDefaults, + NonInteractive: c.Globals.Flags.NonInteractive, + ServiceID: serviceID, + ServiceVersion: serviceVersion, + Setup: c.Globals.Manifest.File.Setup.KVStores, + Stdin: in, + Stdout: out, + } + + sr.secretStores = &setup.SecretStores{ + APIClient: c.Globals.APIClient, + AcceptDefaults: c.Globals.Flags.AcceptDefaults, + NonInteractive: c.Globals.Flags.NonInteractive, + ServiceID: serviceID, + ServiceVersion: serviceVersion, + Setup: c.Globals.Manifest.File.Setup.SecretStores, + Stdin: in, + Stdout: out, + } +} + +// ConfigureServiceResources calls the .Predefined() and .Configure() methods +// for each [setup] resource, which first checks if a [setup] config has been +// defined for the resource type, and if so it prompts the user for details. +func (c *DeployCommand) ConfigureServiceResources(sr ServiceResources, serviceID string, serviceVersion int) error { + // NOTE: A service can't be activated without at least one backend defined. + // This explains why the following block of code isn't wrapped in a call to + // the .Predefined() method, as the call to .Configure() will ensure the + // user is prompted regardless of whether there is a [setup.backends] + // defined in the fastly.toml configuration. + if err := sr.backends.Configure(); err != nil { + errLogService(c.Globals.ErrLog, err, serviceID, serviceVersion) + return fmt.Errorf("error configuring service backends: %w", err) + } + + if sr.configStores.Predefined() { + if err := sr.configStores.Configure(); err != nil { + errLogService(c.Globals.ErrLog, err, serviceID, serviceVersion) + return fmt.Errorf("error configuring service config stores: %w", err) + } + } + + if sr.loggers.Predefined() { + // NOTE: We don't handle errors from the Configure() method because we + // don't actually do anything other than display a message to the user + // informing them that they need to create a log endpoint and which + // provider type they should be. The reason we don't implement logic for + // creating logging objects is because the API input fields vary + // significantly between providers. + _ = sr.loggers.Configure() + } + + if sr.objectStores.Predefined() { + if err := sr.objectStores.Configure(); err != nil { + errLogService(c.Globals.ErrLog, err, serviceID, serviceVersion) + return fmt.Errorf("error configuring service object stores: %w", err) + } + } + + if sr.kvStores.Predefined() { + if err := sr.kvStores.Configure(); err != nil { + errLogService(c.Globals.ErrLog, err, serviceID, serviceVersion) + return fmt.Errorf("error configuring service kv stores: %w", err) + } + } + + if sr.secretStores.Predefined() { + if err := sr.secretStores.Configure(); err != nil { + errLogService(c.Globals.ErrLog, err, serviceID, serviceVersion) + return fmt.Errorf("error configuring service secret stores: %w", err) + } + } + + return nil +} + +// CreateServiceResources makes API calls to create resources that have been +// defined in the fastly.toml [setup] configuration. +func (c *DeployCommand) CreateServiceResources( + sr ServiceResources, + spinner text.Spinner, + serviceID string, + serviceVersion int, +) error { + sr.backends.Spinner = spinner + sr.configStores.Spinner = spinner + sr.objectStores.Spinner = spinner + sr.kvStores.Spinner = spinner + sr.secretStores.Spinner = spinner + + if err := sr.backends.Create(); err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Accept defaults": c.Globals.Flags.AcceptDefaults, + "Auto-yes": c.Globals.Flags.AutoYes, + "Non-interactive": c.Globals.Flags.NonInteractive, + "Service ID": serviceID, + "Service Version": serviceVersion, + }) + return err + } + + if err := sr.configStores.Create(); err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Accept defaults": c.Globals.Flags.AcceptDefaults, + "Auto-yes": c.Globals.Flags.AutoYes, + "Non-interactive": c.Globals.Flags.NonInteractive, + "Service ID": serviceID, + "Service Version": serviceVersion, + }) + return err + } + + if err := sr.objectStores.Create(); err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Accept defaults": c.Globals.Flags.AcceptDefaults, + "Auto-yes": c.Globals.Flags.AutoYes, + "Non-interactive": c.Globals.Flags.NonInteractive, + "Service ID": serviceID, + "Service Version": serviceVersion, + }) + return err + } + + if err := sr.kvStores.Create(); err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Accept defaults": c.Globals.Flags.AcceptDefaults, + "Auto-yes": c.Globals.Flags.AutoYes, + "Non-interactive": c.Globals.Flags.NonInteractive, + "Service ID": serviceID, + "Service Version": serviceVersion, + }) + return err + } + + if err := sr.secretStores.Create(); err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Accept defaults": c.Globals.Flags.AcceptDefaults, + "Auto-yes": c.Globals.Flags.AutoYes, + "Non-interactive": c.Globals.Flags.NonInteractive, + "Service ID": serviceID, + "Service Version": serviceVersion, + }) + return err + } + + return nil +} + +// ProcessService updates the service version comment and then activates the +// service version. +func (c *DeployCommand) ProcessService(serviceID string, serviceVersion int, spinner text.Spinner) (err error) { + defer func() { + event := beacon.Event{ + Name: "activate", + } + if err != nil { + event.Status = beacon.StatusFail + } else { + event.Status = beacon.StatusSuccess + } + bErr := beacon.Notify(c.Globals, serviceID, event) + if bErr != nil { + c.Globals.ErrLog.Add(bErr) + } + }() + + if c.Comment.WasSet { + _, err = c.Globals.APIClient.UpdateVersion(&fastly.UpdateVersionInput{ + ServiceID: serviceID, + ServiceVersion: serviceVersion, + Comment: &c.Comment.Value, + }) + if err != nil { + return fmt.Errorf("error setting comment for service version %d: %w", serviceVersion, err) + } + } + + return spinner.Process(fmt.Sprintf("Activating service (version %d)", serviceVersion), func(_ *text.SpinnerWrapper) error { + _, err = c.Globals.APIClient.ActivateVersion(&fastly.ActivateVersionInput{ + ServiceID: serviceID, + ServiceVersion: serviceVersion, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": serviceVersion, + }) + return fmt.Errorf("error activating version: %w", err) + } + return nil + }) +} + +// GetServiceURL returns the service URL. +func (c *DeployCommand) GetServiceURL(serviceID string, serviceVersion int) (string, error) { + latestDomains, err := c.Globals.APIClient.ListDomains(&fastly.ListDomainsInput{ + ServiceID: serviceID, + ServiceVersion: serviceVersion, + }) + if err != nil { + return "", err + } + name := fastly.ToValue(latestDomains[0].Name) + if segs := strings.Split(name, "*."); len(segs) > 1 { + name = segs[1] + } + return fmt.Sprintf("https://%s", name), nil +} + +// checkingServiceAvailability pings the service URL until either there is a +// non-500 (or whatever status code is configured by the user) or if the +// configured timeout is reached. +func checkingServiceAvailability( + serviceURL string, + spinner text.Spinner, + c *DeployCommand, +) (status int, err error) { + remediation := "The service has been successfully deployed and activated, but the service 'availability' check %s (we were looking for a %s but the last status code response was: %d). If using a custom domain, please be sure to check your DNS settings. Otherwise, your application might be taking longer than usual to deploy across our global network. Please continue to check the service URL and if still unavailable please contact Fastly support." + + dur := time.Duration(c.StatusCheckTimeout) * time.Second + end := time.Now().Add(dur) + timeout := time.After(dur) + ticker := time.NewTicker(1 * time.Second) + defer func() { ticker.Stop() }() + + err = spinner.Start() + if err != nil { + return 0, err + } + msg := "Checking service availability" + spinner.Message(msg + generateTimeout(time.Until(end))) + + expected := "non-500 status code" + if validStatusCodeRange(c.StatusCheckCode) { + expected = fmt.Sprintf("%d status code", c.StatusCheckCode) + } + + // Keep trying until we're timed out, got a result or got an error + for { + select { + case <-timeout: + err := errors.New("timeout: service not yet available") + returnedStatus := fmt.Sprintf(" (status: %d)", status) + spinner.StopFailMessage(msg + returnedStatus) + spinErr := spinner.StopFail() + if spinErr != nil { + return status, fmt.Errorf(text.SpinnerErrWrapper, spinErr, err) + } + return status, fsterr.RemediationError{ + Inner: err, + Remediation: fmt.Sprintf(remediation, "timed out", expected, status), + } + case t := <-ticker.C: + var ( + ok bool + err error + ) + // We overwrite the `status` variable in the parent scope (defined in the + // return arguments list) so it can be used as part of both the timeout + // and success scenarios. + ok, status, err = pingServiceURL(serviceURL, c.Globals.HTTPClient, c.StatusCheckCode, c.Globals.Flags.Debug) + if err != nil { + err := fmt.Errorf("failed to ping service URL: %w", err) + returnedStatus := fmt.Sprintf(" (status: %d)", status) + spinner.StopFailMessage(msg + returnedStatus) + spinErr := spinner.StopFail() + if spinErr != nil { + return status, fmt.Errorf(text.SpinnerErrWrapper, spinErr, err) + } + return status, fsterr.RemediationError{ + Inner: err, + Remediation: fmt.Sprintf(remediation, "failed", expected, status), + } + } + if ok { + returnedStatus := fmt.Sprintf(" (status: %d)", status) + spinner.StopMessage(msg + returnedStatus) + return status, spinner.Stop() + } + // Service not available, and no error, so jump back to top of loop + spinner.Message(msg + generateTimeout(end.Sub(t))) + } + } +} + +// generateTimeout inserts a dynamically generated message on each tick. +// It notifies the user what's happening and how long is left on the timer. +func generateTimeout(d time.Duration) string { + remaining := fmt.Sprintf("timeout: %v", d.Round(time.Second)) + return fmt.Sprintf(" (app deploying across Fastly's global network | %s)...", remaining) +} + +// pingServiceURL indicates if the service returned a non-5xx response (or +// whatever the user defined with --status-check-code), which should help +// signify if the service is generally available. +func pingServiceURL(serviceURL string, httpClient api.HTTPClient, expectedStatusCode int, debugMode bool) (ok bool, status int, err error) { + req, err := http.NewRequest(http.MethodGet, serviceURL, nil) + if err != nil { + return false, 0, err + } + + // gosec flagged this: + // G107 (CWE-88): Potential HTTP request made with variable url + // Disabling as we trust the source of the variable. + // #nosec + if debugMode { + debug.DumpHTTPRequest(req) + } + resp, err := httpClient.Do(req) + if debugMode { + debug.DumpHTTPResponse(resp) + } + if err != nil { + return false, 0, err + } + defer func() { + _ = resp.Body.Close() + }() + + // We check for the user's defined status code expectation. + // Otherwise we'll default to checking for a non-500. + if validStatusCodeRange(expectedStatusCode) && resp.StatusCode == expectedStatusCode { + return true, resp.StatusCode, nil + } else if resp.StatusCode < http.StatusInternalServerError { + return true, resp.StatusCode, nil + } + return false, resp.StatusCode, nil +} + +// ExistingServiceVersion returns a Service Version for an existing service. +// If the current service version is active or locked, we clone the version. +func (c *DeployCommand) ExistingServiceVersion(serviceID string, out io.Writer) (*fastly.Version, error) { + var ( + err error + serviceVersion *fastly.Version + ) + + // There is a scenario where a user already has a Service ID within the + // fastly.toml manifest but they want to deploy their project to a different + // service (e.g. deploy to a staging service). + // + // In this scenario we end up here because we have found a Service ID in the + // manifest but if the --service-name flag is set, then we need to ignore + // what's set in the manifest and instead identify the ID of the service + // name the user has provided. + if c.ServiceName.WasSet { + serviceID, err = c.ServiceName.Parse(c.Globals.APIClient) + if err != nil { + return nil, err + } + } + + serviceVersion, err = c.ServiceVersion.Parse(serviceID, c.Globals.APIClient) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Package path": c.PackagePath, + "Service ID": serviceID, + }) + return nil, err + } + + serviceVersionNumber := fastly.ToValue(serviceVersion.Number) + + // Validate that we're dealing with a Compute 'wasm' service and not a + // VCL service, for which we cannot upload a wasm package format to. + serviceDetails, err := c.Globals.APIClient.GetServiceDetails(&fastly.GetServiceInput{ServiceID: serviceID}) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": serviceVersionNumber, + }) + return serviceVersion, err + } + serviceType := fastly.ToValue(serviceDetails.Type) + if serviceType != "wasm" { + c.Globals.ErrLog.AddWithContext(fmt.Errorf("error: invalid service type: '%s'", serviceType), map[string]any{ + "Service ID": serviceID, + "Service Version": serviceVersionNumber, + "Service Type": serviceType, + }) + return serviceVersion, fsterr.RemediationError{ + Inner: fmt.Errorf("invalid service type: %s", serviceType), + Remediation: "Ensure the provided Service ID is associated with a 'Wasm' Fastly Service and not a 'VCL' Fastly service. " + fsterr.ComputeTrialRemediation, + } + } + + err = c.CompareLocalRemotePackage(serviceID, serviceVersionNumber) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Package path": c.PackagePath, + "Service ID": serviceID, + "Service Version": serviceVersionNumber, + }) + return serviceVersion, err + } + + // Unlike other CLI commands that are a direct mapping to an API endpoint, + // the compute deploy command is a composite of behaviours, and so as we + // already automatically activate a version we should autoclone without + // requiring the user to explicitly provide an --autoclone flag. + if fastly.ToValue(serviceVersion.Active) || fastly.ToValue(serviceVersion.Locked) { + clonedVersion, err := c.Globals.APIClient.CloneVersion(&fastly.CloneVersionInput{ + ServiceID: serviceID, + ServiceVersion: serviceVersionNumber, + }) + if err != nil { + errLogService(c.Globals.ErrLog, err, serviceID, serviceVersionNumber) + return serviceVersion, fmt.Errorf("error cloning service version: %w", err) + } + if c.Globals.Verbose() { + msg := "Service version %d is not editable, so it was automatically cloned. Now operating on version %d.\n\n" + format := fmt.Sprintf(msg, serviceVersionNumber, fastly.ToValue(clonedVersion.Number)) + text.Output(out, format) + } + serviceVersion = clonedVersion + } + + return serviceVersion, nil +} + +func monitorSignals(signalCh chan os.Signal, noExistingService bool, out io.Writer, undoStack *undo.Stack, spinner text.Spinner) { + <-signalCh + signal.Stop(signalCh) + spinner.StopFailMessage("Signal received to interrupt/terminate the Fastly CLI process") + _ = spinner.StopFail() + text.Important(out, "\n\nThe Fastly CLI process will be terminated after any clean-up tasks have been processed") + if noExistingService { + undoStack.Unwind(out) + } + os.Exit(1) +} diff --git a/pkg/commands/compute/deploy_test.go b/pkg/commands/compute/deploy_test.go new file mode 100644 index 000000000..fb720a21a --- /dev/null +++ b/pkg/commands/compute/deploy_test.go @@ -0,0 +1,2341 @@ +package compute_test + +import ( + "context" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/app" + "github.com/fastly/cli/pkg/commands/compute" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" + "github.com/fastly/cli/pkg/threadsafe" +) + +// NOTE: Some tests don't provide a Service ID via any mechanism (e.g. flag +// or manifest) and if one is provided the test will fail due to a specific +// API call not being mocked. Be careful not to add a Service ID to all tests +// without first checking whether the Service ID is expected as the user flow +// for when no Service ID is provided is to create a new service. +// +// Additionally, stdin can be mocked in one of two ways... +// +// 1. Provide a single value. +// 2. Provide multiple values (one for each prompt expected). +// +// In the first case, the first prompt given to the user will get the value you +// defined in the testcase.stdin field, all other prompts will get an empty +// value. This has worked fine for the most part as the prompts have +// historically provided default values when an empty value is encountered. +// +// The second case is to address running the test code successfully as the +// business logic has changed over time to now 'require' values to be provided +// for some prompts, this means an empty string will break the test flow. If +// that's what you're encountering, then you should add multiple values for the +// testcase.stdin field so that there is a value provided for every prompt your +// testcase user flow expects to encounter. +func TestDeploy(t *testing.T) { + if os.Getenv("TEST_COMPUTE_DEPLOY") == "" { + t.Log("skipping test") + t.Skip("Set TEST_COMPUTE_DEPLOY to run this test") + } + + // We're going to chdir to a deploy environment, + // so save the PWD to return to, afterwards. + pwd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + + // Create test environment + rootdir := testutil.NewEnv(testutil.EnvOpts{ + T: t, + Copy: []testutil.FileIO{ + { + Src: filepath.Join("testdata", "deploy", "pkg", "package.tar.gz"), + Dst: filepath.Join("pkg", "package.tar.gz"), + }, + }, + Write: []testutil.FileIO{ + { + Src: "This is my data for the KV Store 'store_one' baz field.", + Dst: "kv_store_one_baz.txt", + }, + }, + }) + defer os.RemoveAll(rootdir) + + // Before running the test, chdir into the build environment. + // When we're done, chdir back to our original location. + // This is so we can reliably copy the testdata/ fixtures. + if err := os.Chdir(rootdir); err != nil { + t.Fatal(err) + } + defer func() { + _ = os.Chdir(pwd) + }() + + originalPackageSizeLimit := compute.MaxPackageSize + args := testutil.SplitArgs + scenarios := []struct { + api mock.API + args []string + dontWantOutput []string + // There are two times the HTTPClient is used. + // The first is if we need to activate a free trial. + // The second is when we ping for service availability. + // In this test case the free trial activation isn't used. + // So we only define a single HTTP client call for service availability. + httpClientRes []*http.Response + httpClientErr []error + manifest string + name string + noManifest bool + reduceSizeLimit bool + stdin []string + wantError string + wantRemediationError string + wantOutput []string + }{ + { + name: "no fastly.toml manifest", + args: args("compute deploy --token 123"), + wantError: "error reading fastly.toml", + wantRemediationError: errors.ComputeInitRemediation, + noManifest: true, + }, + { + // If no Service ID defined via flag or manifest, then the expectation is + // for the service to be created via the API and for the returned ID to + // be stored into the manifest. + // + // Additionally it validates that the specified path (files generated by + // the testutil.NewEnv()) cause no issues. + name: "path with no service ID", + args: args("compute deploy --token 123 -v --package pkg/package.tar.gz"), + api: mock.API{ + ActivateVersionFn: activateVersionOk, + CreateBackendFn: createBackendOK, + CreateDomainFn: createDomainOK, + CreateServiceFn: createServiceOK, + GetPackageFn: getPackageOk, + ListDomainsFn: listDomainsOk, + UpdatePackageFn: updatePackageOk, + }, + httpClientRes: []*http.Response{ + mock.NewHTTPResponse(http.StatusNoContent, nil, nil), + mock.NewHTTPResponse(http.StatusOK, nil, io.NopCloser(strings.NewReader("success"))), + }, + httpClientErr: []error{ + nil, + nil, + }, + stdin: []string{ + "Y", // when prompted to create a new service + }, + wantOutput: []string{ + "Deployed package (service 12345, version 1)", + }, + }, + // Same validation as above with the exception that we use the default path + // parsing logic (i.e. we don't explicitly pass a path via `-p` flag). + { + name: "empty service ID", + args: args("compute deploy --token 123 -v"), + api: mock.API{ + ActivateVersionFn: activateVersionOk, + CreateBackendFn: createBackendOK, + CreateDomainFn: createDomainOK, + CreateServiceFn: createServiceOK, + GetPackageFn: getPackageOk, + ListDomainsFn: listDomainsOk, + UpdatePackageFn: updatePackageOk, + }, + httpClientRes: []*http.Response{ + mock.NewHTTPResponse(http.StatusNoContent, nil, nil), + mock.NewHTTPResponse(http.StatusOK, nil, io.NopCloser(strings.NewReader("success"))), + }, + httpClientErr: []error{ + nil, + nil, + }, + stdin: []string{ + "Y", // when prompted to create a new service + }, + wantOutput: []string{ + "Deployed package (service 12345, version 1)", + }, + }, + { + name: "list versions error", + args: args("compute deploy --service-id 123 --token 123"), + api: mock.API{ + GetServiceFn: getServiceOK, + ListVersionsFn: testutil.ListVersionsError, + }, + wantError: fmt.Sprintf("error listing service versions: %s", testutil.Err.Error()), + }, + { + name: "service version is active, clone version error", + args: args("compute deploy --service-id 123 --token 123 --version 1"), + api: mock.API{ + CloneVersionFn: testutil.CloneVersionError, + GetPackageFn: getPackageOk, + GetServiceDetailsFn: getServiceDetailsWasm, + ListVersionsFn: testutil.ListVersions, + }, + wantError: fmt.Sprintf("error cloning service version: %s", testutil.Err.Error()), + }, + { + name: "service version is locked, clone version error", + args: args("compute deploy --service-id 123 --token 123 --version 2"), + api: mock.API{ + CloneVersionFn: testutil.CloneVersionError, + GetPackageFn: getPackageOk, + GetServiceDetailsFn: getServiceDetailsWasm, + ListVersionsFn: testutil.ListVersions, + }, + wantError: fmt.Sprintf("error cloning service version: %s", testutil.Err.Error()), + }, + { + name: "list domains error", + args: args("compute deploy --service-id 123 --token 123"), + api: mock.API{ + CloneVersionFn: testutil.CloneVersionResult(4), + GetPackageFn: getPackageOk, + GetServiceDetailsFn: getServiceDetailsWasm, + GetServiceFn: getServiceOK, + ListDomainsFn: listDomainsError, + ListVersionsFn: testutil.ListVersions, + }, + wantError: fmt.Sprintf("error fetching service domains: %s", testutil.Err.Error()), + }, + { + name: "package size too large", + args: args("compute deploy --package pkg/package.tar.gz --token 123"), + reduceSizeLimit: true, + wantError: "package size is too large", + wantRemediationError: errors.PackageSizeRemediation, + }, + // The following test doesn't just validate the package API error behaviour + // but as a side effect it validates that when deleting the created + // service, the Service ID is also cleared out from the manifest. + { + name: "package API error", + args: args("compute deploy --token 123"), + api: mock.API{ + CreateBackendFn: createBackendOK, + CreateDomainFn: createDomainOK, + CreateServiceFn: createServiceOK, + DeleteBackendFn: deleteBackendOK, + DeleteDomainFn: deleteDomainOK, + DeleteServiceFn: deleteServiceOK, + GetPackageFn: getPackageOk, + ListDomainsFn: listDomainsOk, + UpdatePackageFn: updatePackageError, + }, + stdin: []string{ + "Y", // when prompted to create a new service + }, + wantError: fmt.Sprintf("error uploading package: %s", testutil.Err.Error()), + wantOutput: []string{ + "Uploading package", + }, + }, + // The following test doesn't provide a Service ID by either a flag nor the + // manifest, so this will result in the deploy script attempting to create + // a new service. We mock the API call to fail, and we expect to see a + // relevant error message related to that error. + { + name: "service create error", + args: args("compute deploy --token 123"), + api: mock.API{ + CreateServiceFn: createServiceError, + DeleteServiceFn: deleteServiceOK, + }, + stdin: []string{ + "Y", // when prompted to create a new service + }, + wantError: fmt.Sprintf("error creating service: %s", testutil.Err.Error()), + }, + // The following test mocks the service creation to fail with a specific + // error value that will result in the code trying to activate a free trial + // for the customer's account. + // + // Specifically this test will fail the initial API call to get the + // customer's details and so we expect it to return that error (as we can't + // activate a free trial without knowing the customer ID). + { + name: "service create error due to no trial activated and error getting user", + args: args("compute deploy --token 123"), + api: mock.API{ + CreateServiceFn: createServiceErrorNoTrial, + DeleteServiceFn: deleteServiceOK, + GetCurrentUserFn: getCurrentUserError, + }, + stdin: []string{ + "Y", // when prompted to create a new service + }, + wantError: fmt.Sprintf("unable to identify user associated with the given token: %s", testutil.Err.Error()), + wantOutput: []string{ + "Creating service", + }, + }, + // The following test mocks the HTTP client to return a 400 Bad Request, + // which is then coerced into a generic 'no free trial' error. + { + name: "service create error due to no trial activated and error activating trial", + args: args("compute deploy --token 123"), + api: mock.API{ + CreateServiceFn: createServiceErrorNoTrial, + DeleteServiceFn: deleteServiceOK, + GetCurrentUserFn: getCurrentUser, + }, + httpClientRes: []*http.Response{ + { + Body: io.NopCloser(strings.NewReader(testutil.Err.Error())), + Status: http.StatusText(http.StatusBadRequest), + StatusCode: http.StatusBadRequest, + }, + }, + httpClientErr: []error{ + nil, + }, + stdin: []string{ + "Y", // when prompted to create a new service + }, + wantError: "error creating service: you do not have the Compute free trial enabled on your Fastly account", + wantRemediationError: errors.ComputeTrialRemediation, + wantOutput: []string{ + "Creating service", + }, + }, + // The following test mocks the HTTP client to return a timeout error, + // which is then coerced into a generic 'no free trial' error. + { + name: "service create error due to no trial activated and activating trial timeout", + args: args("compute deploy --token 123"), + api: mock.API{ + CreateServiceFn: createServiceErrorNoTrial, + DeleteServiceFn: deleteServiceOK, + GetCurrentUserFn: getCurrentUser, + }, + httpClientRes: []*http.Response{ + nil, + }, + httpClientErr: []error{ + &url.Error{Err: context.DeadlineExceeded}, + }, + stdin: []string{ + "Y", // when prompted to create a new service + }, + wantError: "error creating service: you do not have the Compute free trial enabled on your Fastly account", + wantRemediationError: errors.ComputeTrialRemediation, + wantOutput: []string{ + "Creating service", + }, + }, + // The following test mocks the HTTP client to return successfully when + // trying to activate the free trial. + { + name: "service create success", + args: args("compute deploy --token 123"), + api: mock.API{ + ActivateVersionFn: activateVersionOk, + CreateBackendFn: createBackendOK, + CreateServiceFn: createServiceOK, + GetPackageFn: getPackageOk, + ListDomainsFn: listDomainsOk, + UpdatePackageFn: updatePackageOk, + }, + httpClientRes: []*http.Response{ + mock.NewHTTPResponse(http.StatusNoContent, nil, nil), + mock.NewHTTPResponse(http.StatusOK, nil, io.NopCloser(strings.NewReader("success"))), + }, + httpClientErr: []error{ + nil, + nil, + }, + stdin: []string{ + "Y", // when prompted to create a new service + }, + wantOutput: []string{ + "Creating service", + }, + }, + // The following test doesn't provide a Service ID by either a flag nor the + // manifest, so this will result in the deploy script attempting to create + // a new service. We mock the service creation to be successful while we + // mock the domain API call to fail, and we expect to see a relevant error + // message related to that error. + { + name: "service domain error", + args: args("compute deploy --token 123"), + api: mock.API{ + CreateDomainFn: createDomainError, + CreateServiceFn: createServiceOK, + DeleteDomainFn: deleteDomainOK, + DeleteServiceFn: deleteServiceOK, + ListDomainsFn: listDomainsNone, + }, + stdin: []string{ + "Y", // when prompted to create a new service + }, + wantError: fmt.Sprintf("error creating domain: %s", testutil.Err.Error()), + wantOutput: []string{ + "Creating service", + }, + }, + // The following test doesn't provide a Service ID by either a flag nor the + // manifest, so this will result in the deploy script attempting to create + // a new service. We mock the service creation to be successful while we + // mock the backend API call to succeed but to return an unexpected empty + // list of Backends. + { + name: "service backend error", + args: args("compute deploy --token 123"), + api: mock.API{ + CreateBackendFn: createBackendError, + CreateDomainFn: createDomainOK, + CreateServiceFn: createServiceOK, + DeleteBackendFn: deleteBackendOK, + DeleteDomainFn: deleteDomainOK, + DeleteServiceFn: deleteServiceOK, + ListDomainsFn: listDomainsOk, + }, + stdin: []string{ + "Y", // when prompted to create a new service + }, + wantError: fmt.Sprintf("error configuring the service: %s", testutil.Err.Error()), + wantOutput: []string{ + "Creating service", + }, + dontWantOutput: []string{ + "Creating domain '", + }, + }, + // The following test validates that the undoStack is executed as expected + // e.g. the service is deleted when there is an error during the flow. + // This only happens for new service flows. + { + name: "undo stack is executed", + args: args("compute deploy --token 123"), + api: mock.API{ + CreateBackendFn: createBackendError, + CreateDomainFn: createDomainOK, + CreateServiceFn: createServiceOK, + DeleteServiceFn: deleteServiceOK, + ListDomainsFn: listDomainsNone, + }, + stdin: []string{ + "Y", // when prompted to create a new service + }, + wantError: fmt.Sprintf("error configuring the service: %s", testutil.Err.Error()), + wantOutput: []string{ + "Cleaning up service", + "Removing Service ID from fastly.toml", + "Cleanup complete", + }, + }, + // The following test is the opposite to the above test. + // It validates that we don't delete an existing service on-error. + { + name: "undo stack is not executed for errors with existing services", + args: args("compute deploy --service-id 123 --token 123"), + api: mock.API{ + ActivateVersionFn: activateVersionError, + CloneVersionFn: testutil.CloneVersionResult(4), + GetPackageFn: getPackageOk, + GetServiceDetailsFn: getServiceDetailsWasm, + GetServiceFn: getServiceOK, + ListDomainsFn: listDomainsOk, + ListVersionsFn: testutil.ListVersions, + UpdatePackageFn: updatePackageOk, + }, + dontWantOutput: []string{ + "Cleaning up service", + "Removing Service ID from fastly.toml", + "Cleanup complete", + }, + wantError: "error activating version: test error", + wantOutput: []string{ + "Uploading package", + "Activating service", + }, + }, + // The following test validates that if a package contains code that has + // not changed since the last deploy, then the deployment is skipped. + { + name: "identical package", + args: args("compute deploy --service-id 123 --token 123"), + api: mock.API{ + CloneVersionFn: testutil.CloneVersionResult(4), + GetPackageFn: getPackageIdentical, + GetServiceDetailsFn: getServiceDetailsWasm, + GetServiceFn: getServiceOK, + ListDomainsFn: listDomainsOk, + ListVersionsFn: testutil.ListVersions, + }, + wantOutput: []string{ + "Skipping package deployment", + }, + }, + { + name: "success with existing service", + args: args("compute deploy --service-id 123 --token 123"), + api: mock.API{ + ActivateVersionFn: activateVersionOk, + CloneVersionFn: testutil.CloneVersionResult(4), + GetPackageFn: getPackageOk, + GetServiceDetailsFn: getServiceDetailsWasm, + GetServiceFn: getServiceOK, + ListDomainsFn: listDomainsOk, + ListVersionsFn: testutil.ListVersions, + UpdatePackageFn: updatePackageOk, + }, + httpClientRes: []*http.Response{ + mock.NewHTTPResponse(http.StatusNoContent, nil, nil), + mock.NewHTTPResponse(http.StatusOK, nil, io.NopCloser(strings.NewReader("success"))), + }, + httpClientErr: []error{ + nil, + nil, + }, + wantOutput: []string{ + "Uploading package", + "Activating service", + "Manage this service at:", + "https://manage.fastly.com/configure/services/123", + "View this service at:", + "https://directly-careful-coyote.edgecompute.app", + "Deployed package (service 123, version 4)", + }, + }, + { + name: "success with path", + args: args("compute deploy --service-id 123 --token 123 --package pkg/package.tar.gz --version 3"), + api: mock.API{ + ActivateVersionFn: activateVersionOk, + GetPackageFn: getPackageOk, + GetServiceDetailsFn: getServiceDetailsWasm, + GetServiceFn: getServiceOK, + ListDomainsFn: listDomainsOk, + ListVersionsFn: testutil.ListVersions, + UpdatePackageFn: updatePackageOk, + }, + httpClientRes: []*http.Response{ + mock.NewHTTPResponse(http.StatusNoContent, nil, nil), + mock.NewHTTPResponse(http.StatusOK, nil, io.NopCloser(strings.NewReader("success"))), + }, + httpClientErr: []error{ + nil, + nil, + }, + wantOutput: []string{ + "Uploading package", + "Activating service", + "Manage this service at:", + "https://manage.fastly.com/configure/services/123", + "View this service at:", + "https://directly-careful-coyote.edgecompute.app", + "Deployed package (service 123, version 3)", + }, + }, + // NOTE: The following test ensures that if the user runs the CLI from a + // directory that isn't a Compute project directory (i.e. it has no manifest + // file present) then the deploy command should try to locate a manifest + // inside the given package tar.gz archive. + { + name: "success with path called from non project directory", + args: args("compute deploy --service-id 123 --token 123 --package pkg/package.tar.gz --version 3 --verbose"), + api: mock.API{ + ActivateVersionFn: activateVersionOk, + GetPackageFn: getPackageOk, + GetServiceDetailsFn: getServiceDetailsWasm, + GetServiceFn: getServiceOK, + ListDomainsFn: listDomainsOk, + ListVersionsFn: testutil.ListVersions, + UpdatePackageFn: updatePackageOk, + }, + httpClientRes: []*http.Response{ + mock.NewHTTPResponse(http.StatusNoContent, nil, nil), + mock.NewHTTPResponse(http.StatusOK, nil, io.NopCloser(strings.NewReader("success"))), + }, + httpClientErr: []error{ + nil, + nil, + }, + noManifest: true, + wantOutput: []string{ + "Using fastly.toml within --package archive:", + "Uploading package", + "Activating service", + "Manage this service at:", + "https://manage.fastly.com/configure/services/123", + "View this service at:", + "https://directly-careful-coyote.edgecompute.app", + "Deployed package (service 123, version 3)", + }, + }, + { + name: "success with inactive version", + args: args("compute deploy --service-id 123 --token 123 --package pkg/package.tar.gz --version 3"), + api: mock.API{ + ActivateVersionFn: activateVersionOk, + GetPackageFn: getPackageOk, + GetServiceDetailsFn: getServiceDetailsWasm, + GetServiceFn: getServiceOK, + ListDomainsFn: listDomainsOk, + ListVersionsFn: testutil.ListVersions, + UpdatePackageFn: updatePackageOk, + }, + httpClientRes: []*http.Response{ + mock.NewHTTPResponse(http.StatusNoContent, nil, nil), + mock.NewHTTPResponse(http.StatusOK, nil, io.NopCloser(strings.NewReader("success"))), + }, + httpClientErr: []error{ + nil, + nil, + }, + wantOutput: []string{ + "Uploading package", + "Activating service", + "Deployed package (service 123, version 3)", + }, + }, + { + name: "success with specific locked version", + args: args("compute deploy --service-id 123 --token 123 --package pkg/package.tar.gz --version 2"), + api: mock.API{ + ActivateVersionFn: activateVersionOk, + CloneVersionFn: testutil.CloneVersionResult(4), + GetPackageFn: getPackageOk, + GetServiceDetailsFn: getServiceDetailsWasm, + GetServiceFn: getServiceOK, + ListDomainsFn: listDomainsOk, + ListVersionsFn: testutil.ListVersions, + UpdatePackageFn: updatePackageOk, + }, + httpClientRes: []*http.Response{ + mock.NewHTTPResponse(http.StatusNoContent, nil, nil), + mock.NewHTTPResponse(http.StatusOK, nil, io.NopCloser(strings.NewReader("success"))), + }, + httpClientErr: []error{ + nil, + nil, + }, + wantOutput: []string{ + "Uploading package", + "Activating service", + "Deployed package (service 123, version 4)", + }, + }, + { + name: "success with active version", + args: args("compute deploy --service-id 123 --token 123 --package pkg/package.tar.gz --version active"), + api: mock.API{ + ActivateVersionFn: activateVersionOk, + CloneVersionFn: testutil.CloneVersionResult(4), + GetPackageFn: getPackageOk, + GetServiceDetailsFn: getServiceDetailsWasm, + GetServiceFn: getServiceOK, + ListDomainsFn: listDomainsOk, + ListVersionsFn: testutil.ListVersions, + UpdatePackageFn: updatePackageOk, + }, + httpClientRes: []*http.Response{ + mock.NewHTTPResponse(http.StatusNoContent, nil, nil), + mock.NewHTTPResponse(http.StatusOK, nil, io.NopCloser(strings.NewReader("success"))), + }, + httpClientErr: []error{ + nil, + nil, + }, + wantOutput: []string{ + "Uploading package", + "Activating service", + "Deployed package (service 123, version 4)", + }, + }, + { + name: "success with comment", + args: args("compute deploy --service-id 123 --token 123 --package pkg/package.tar.gz --version 2 --comment foo"), + api: mock.API{ + ActivateVersionFn: activateVersionOk, + CloneVersionFn: testutil.CloneVersionResult(4), + GetPackageFn: getPackageOk, + GetServiceDetailsFn: getServiceDetailsWasm, + GetServiceFn: getServiceOK, + ListDomainsFn: listDomainsOk, + ListVersionsFn: testutil.ListVersions, + UpdatePackageFn: updatePackageOk, + UpdateVersionFn: updateVersionOk, + }, + httpClientRes: []*http.Response{ + mock.NewHTTPResponse(http.StatusNoContent, nil, nil), + mock.NewHTTPResponse(http.StatusOK, nil, io.NopCloser(strings.NewReader("success"))), + }, + httpClientErr: []error{ + nil, + nil, + }, + wantOutput: []string{ + "Uploading package", + "Activating service", + "Deployed package (service 123, version 4)", + }, + }, + // The following test doesn't provide a Service ID by either a flag nor the + // manifest, so this will result in the deploy script attempting to create + // a new service. Our fastly.toml is configured with a [setup] section so + // we expect to see the appropriate messaging in the output. + { + name: "success with setup.backends configuration", + args: args("compute deploy --token 123"), + api: mock.API{ + ActivateVersionFn: activateVersionOk, + CreateBackendFn: createBackendOK, + CreateDomainFn: createDomainOK, + CreateServiceFn: createServiceOK, + DeleteServiceFn: deleteServiceOK, + GetPackageFn: getPackageOk, + ListDomainsFn: listDomainsOk, + UpdatePackageFn: updatePackageOk, + }, + httpClientRes: []*http.Response{ + mock.NewHTTPResponse(http.StatusNoContent, nil, nil), + mock.NewHTTPResponse(http.StatusOK, nil, io.NopCloser(strings.NewReader("success"))), + }, + httpClientErr: []error{ + nil, + nil, + }, + manifest: ` + name = "package" + manifest_version = 2 + language = "rust" + + [setup.backends.backend_name] + prompt = "Backend 1" + address = "developer.fastly.com" + port = 443 + [setup.backends.other_backend_name] + prompt = "Backend 2" + address = "httpbin.org" + port = 443 + `, + stdin: []string{ + "Y", // when prompted to create a new service + }, + wantOutput: []string{ + "Hostname or IP address: [developer.fastly.com]", + "Port: [443]", + "Hostname or IP address: [httpbin.org]", + "Port: [443]", + "Creating service", + "Creating backend 'backend_name' (host: developer.fastly.com, port: 443)", + "Creating backend 'other_backend_name' (host: httpbin.org, port: 443)", + "Uploading package", + "Activating service", + "SUCCESS: Deployed package (service 12345, version 1)", + }, + }, + // The following [setup] configuration doesn't define any prompts, nor any + // ports, so we validate that the user prompts match our default expectations. + { + name: "success with setup.backends configuration and no prompts or ports defined", + args: args("compute deploy --token 123"), + api: mock.API{ + ActivateVersionFn: activateVersionOk, + CreateBackendFn: createBackendOK, + CreateDomainFn: createDomainOK, + CreateServiceFn: createServiceOK, + DeleteServiceFn: deleteServiceOK, + GetPackageFn: getPackageOk, + ListDomainsFn: listDomainsOk, + UpdatePackageFn: updatePackageOk, + }, + httpClientRes: []*http.Response{ + mock.NewHTTPResponse(http.StatusNoContent, nil, nil), + mock.NewHTTPResponse(http.StatusOK, nil, io.NopCloser(strings.NewReader("success"))), + }, + httpClientErr: []error{ + nil, + nil, + }, + manifest: ` + name = "package" + manifest_version = 2 + language = "rust" + + [setup.backends.foo_backend] + address = "developer.fastly.com" + [setup.backends.bar_backend] + address = "httpbin.org" + `, + stdin: []string{ + "Y", // when prompted to create a new service + }, + wantOutput: []string{ + "Hostname or IP address: [developer.fastly.com]", + "Port: [443]", + "Hostname or IP address: [httpbin.org]", + "Port: [443]", + "Creating service", + "Creating backend 'foo_backend' (host: developer.fastly.com, port: 443)", + "Creating backend 'bar_backend' (host: httpbin.org, port: 443)", + "Uploading package", + "Activating service", + "SUCCESS: Deployed package (service 12345, version 1)", + }, + dontWantOutput: []string{ + "Creating domain '", + }, + }, + { + name: "success with setup.backends configuration but no fields for the required resources", + args: args("compute deploy --token 123"), + api: mock.API{ + ActivateVersionFn: activateVersionOk, + CreateBackendFn: createBackendOK, + CreateDomainFn: createDomainOK, + CreateServiceFn: createServiceOK, + DeleteServiceFn: deleteServiceOK, + GetPackageFn: getPackageOk, + ListDomainsFn: listDomainsOk, + UpdatePackageFn: updatePackageOk, + }, + httpClientRes: []*http.Response{ + mock.NewHTTPResponse(http.StatusNoContent, nil, nil), + mock.NewHTTPResponse(http.StatusOK, nil, io.NopCloser(strings.NewReader("success"))), + }, + httpClientErr: []error{ + nil, + nil, + }, + manifest: ` + name = "package" + manifest_version = 2 + language = "rust" + + [setup.backends.foo_backend] + [setup.backends.bar_backend] + `, + stdin: []string{ + "Y", // when prompted to create a new service + }, + wantOutput: []string{ + "Configure a backend called 'foo_backend'", + "Hostname or IP address: [127.0.0.1]", + "Port: [443]", + "Configure a backend called 'bar_backend'", + "Hostname or IP address: [127.0.0.1]", + "Port: [443]", + "Creating service", + "Creating backend 'foo_backend' (host: 127.0.0.1, port: 443)", + "Creating backend 'bar_backend' (host: 127.0.0.1, port: 443)", + "Uploading package", + "Activating service", + "SUCCESS: Deployed package (service 12345, version 1)", + }, + dontWantOutput: []string{ + "Creating domain '", + }, + }, + // The following test validates no prompts are displayed to the user due to + // the use of the --non-interactive flag. + { + name: "success with setup.backends configuration and non-interactive", + args: args("compute deploy --non-interactive --token 123"), + api: mock.API{ + ActivateVersionFn: activateVersionOk, + CreateBackendFn: createBackendOK, + CreateDomainFn: createDomainOK, + CreateServiceFn: createServiceOK, + GetPackageFn: getPackageOk, + ListDomainsFn: listDomainsOk, + UpdatePackageFn: updatePackageOk, + }, + httpClientRes: []*http.Response{ + mock.NewHTTPResponse(http.StatusNoContent, nil, nil), + mock.NewHTTPResponse(http.StatusOK, nil, io.NopCloser(strings.NewReader("success"))), + }, + httpClientErr: []error{ + nil, + nil, + }, + manifest: ` + name = "package" + manifest_version = 2 + language = "rust" + + [setup.backends.backend_name] + description = "Backend 1" + address = "developer.fastly.com" + port = 443 + [setup.backends.other_backend_name] + description = "Backend 2" + address = "httpbin.org" + port = 443 + `, + wantOutput: []string{ + "Creating service", + "Creating backend 'backend_name' (host: developer.fastly.com, port: 443)", + "Creating backend 'other_backend_name' (host: httpbin.org, port: 443)", + "Uploading package", + "Activating service", + "SUCCESS: Deployed package (service 12345, version 1)", + }, + dontWantOutput: []string{ + "Backend 1: [developer.fastly.com]", + "Backend port number: [443]", + "Backend 2: [httpbin.org]", + "Backend port number: [443]", + "Domain: [", + }, + }, + // The following test validates that a new 'originless' backend is created + // when the user has no [setup] configuration and they also pass the + // --non-interactive flag. This is done by ensuring we DON'T see the + // standard 'Creating backend' output because we want to conceal the fact + // that we require a backend for Compute services because it's a temporary + // implementation detail. + { + name: "success with no setup.backends configuration and non-interactive for new service creation", + args: args("compute deploy --non-interactive --token 123"), + api: mock.API{ + ActivateVersionFn: activateVersionOk, + CreateBackendFn: createBackendOK, + CreateDomainFn: createDomainOK, + CreateServiceFn: createServiceOK, + GetPackageFn: getPackageOk, + ListDomainsFn: listDomainsOk, + UpdatePackageFn: updatePackageOk, + }, + httpClientRes: []*http.Response{ + mock.NewHTTPResponse(http.StatusNoContent, nil, nil), + mock.NewHTTPResponse(http.StatusOK, nil, io.NopCloser(strings.NewReader("success"))), + }, + httpClientErr: []error{ + nil, + nil, + }, + wantOutput: []string{ + "SUCCESS: Deployed package (service 12345, version 1)", + }, + dontWantOutput: []string{ + "Creating backend", // expect originless creation to be hidden + }, + }, + { + name: "success with no setup.backends configuration and single backend entered at prompt for new service", + args: args("compute deploy --token 123"), + api: mock.API{ + ActivateVersionFn: activateVersionOk, + CreateBackendFn: createBackendOK, + CreateDomainFn: createDomainOK, + CreateServiceFn: createServiceOK, + GetPackageFn: getPackageOk, + ListDomainsFn: listDomainsOk, + UpdatePackageFn: updatePackageOk, + }, + httpClientRes: []*http.Response{ + mock.NewHTTPResponse(http.StatusNoContent, nil, nil), + mock.NewHTTPResponse(http.StatusOK, nil, io.NopCloser(strings.NewReader("success"))), + }, + httpClientErr: []error{ + nil, + nil, + }, + stdin: []string{ + "Y", // when prompted to create a new service + "foobar", // when prompted for service name + "fastly.com", + "443", + "my_backend_name", + "", // this stops prompting for backends + }, + wantOutput: []string{ + "Backend (hostname or IP address, or leave blank to stop adding backends):", + "Backend port number: [443]", + "Backend name:", + "Creating backend 'my_backend_name' (host: fastly.com, port: 443)", + "SUCCESS: Deployed package (service 12345, version 1)", + }, + }, + // This is the same test as above but when prompted it will provide two + // backends instead of one, and will also allow the code to generate the + // backend name using its predefined formula. + { + name: "success with no setup.backends configuration and multiple backends entered at prompt for new service", + args: args("compute deploy --token 123"), + api: mock.API{ + ActivateVersionFn: activateVersionOk, + CreateBackendFn: createBackendOK, + CreateDomainFn: createDomainOK, + CreateServiceFn: createServiceOK, + GetPackageFn: getPackageOk, + ListDomainsFn: listDomainsOk, + UpdatePackageFn: updatePackageOk, + }, + httpClientRes: []*http.Response{ + mock.NewHTTPResponse(http.StatusNoContent, nil, nil), + mock.NewHTTPResponse(http.StatusOK, nil, io.NopCloser(strings.NewReader("success"))), + }, + httpClientErr: []error{ + nil, + nil, + }, + stdin: []string{ + "Y", // when prompted to create a new service + "foobar", // when prompted for service name + "fastly.com", // when prompted for a backend + "443", + "", // this is so we generate a backend name using a built-in formula + "google.com", + "123", + "", // this is so we generate a backend name using a built-in formula + "", // this stops prompting for backends + }, + wantOutput: []string{ + "Backend (hostname or IP address, or leave blank to stop adding backends):", + "Backend port number: [443]", + "Backend name:", + "Creating backend 'backend_1' (host: fastly.com, port: 443)", + "Creating backend 'backend_2' (host: google.com, port: 123)", + "SUCCESS: Deployed package (service 12345, version 1)", + }, + }, + // The following test validates that when prompting the user for backends + // that we'll default to creating an 'originless' backend if no value + // provided at the prompt. + { + name: "success with no setup.backends configuration and defaulting to originless", + args: args("compute deploy --token 123"), + api: mock.API{ + ActivateVersionFn: activateVersionOk, + CreateBackendFn: createBackendOK, + CreateDomainFn: createDomainOK, + CreateServiceFn: createServiceOK, + GetPackageFn: getPackageOk, + ListDomainsFn: listDomainsOk, + UpdatePackageFn: updatePackageOk, + }, + httpClientRes: []*http.Response{ + mock.NewHTTPResponse(http.StatusNoContent, nil, nil), + mock.NewHTTPResponse(http.StatusOK, nil, io.NopCloser(strings.NewReader("success"))), + }, + httpClientErr: []error{ + nil, + nil, + }, + stdin: []string{ + "Y", // when prompted to create a new service + "foobar", // when prompted for service name + "", // this stops prompting for backends + }, + wantOutput: []string{ + "Backend (hostname or IP address, or leave blank to stop adding backends):", + "SUCCESS: Deployed package (service 12345, version 1)", + }, + dontWantOutput: []string{ + "Creating backend", // expect originless creation to be hidden + }, + }, + // The following test is the same setup as above, but if the user provides + // the --non-interactive flag we won't prompt for any backends. + { + name: "success with no setup.backends configuration and use of --non-interactive", + args: args("compute deploy --non-interactive --token 123"), + api: mock.API{ + ActivateVersionFn: activateVersionOk, + CreateBackendFn: createBackendOK, + CreateServiceFn: createServiceOK, + GetPackageFn: getPackageOk, + GetServiceDetailsFn: getServiceDetailsWasm, + GetServiceFn: getServiceOK, + ListDomainsFn: listDomainsOk, + ListVersionsFn: testutil.ListVersions, + UpdatePackageFn: updatePackageOk, + }, + httpClientRes: []*http.Response{ + mock.NewHTTPResponse(http.StatusNoContent, nil, nil), + mock.NewHTTPResponse(http.StatusOK, nil, io.NopCloser(strings.NewReader("success"))), + }, + httpClientErr: []error{ + nil, + nil, + }, + wantOutput: []string{ + "SUCCESS: Deployed package (service 12345, version 1)", + }, + dontWantOutput: []string{ + "Create new service", + "Creating backend", // expect originless creation to be hidden + }, + }, + // The following test validates that when dealing with an existing service, + // no [setup.backends] configuration is utilised. + // + // i.e. we will not validate the service for missing backends, nor will we + // prompt the user to create any backends. + { + name: "success with setup.backends configuration and existing service", + args: args("compute deploy --service-id 123 --token 123"), + api: mock.API{ + ActivateVersionFn: activateVersionOk, + CloneVersionFn: testutil.CloneVersionResult(4), + CreateBackendFn: createBackendOK, + GetPackageFn: getPackageOk, + GetServiceDetailsFn: getServiceDetailsWasm, + GetServiceFn: getServiceOK, + ListDomainsFn: listDomainsOk, + ListVersionsFn: testutil.ListVersions, + UpdatePackageFn: updatePackageOk, + }, + httpClientRes: []*http.Response{ + mock.NewHTTPResponse(http.StatusNoContent, nil, nil), + mock.NewHTTPResponse(http.StatusOK, nil, io.NopCloser(strings.NewReader("success"))), + }, + httpClientErr: []error{ + nil, + nil, + }, + manifest: ` + name = "package" + manifest_version = 2 + language = "rust" + + [setup.backends.fastly] + description = "Backend 1" + address = "fastly.com" + port = 443 + [setup.backends.google] + description = "Backend 2" + address = "google.com" + port = 443 + [setup.backends.facebook] + description = "Backend 3" + address = "facebook.com" + port = 443 + `, + wantOutput: []string{ + "Uploading package", + "Activating service", + "SUCCESS: Deployed package (service 123, version 4)", + }, + dontWantOutput: []string{ + "Creating backend 'google' (host: beep.com, port: 123)", + "Creating backend 'facebook' (host: boop.com, port: 456)", + }, + }, + { + name: "success with setup.config_stores configuration and existing service", + args: args("compute deploy --service-id 123 --token 123"), + api: mock.API{ + ActivateVersionFn: activateVersionOk, + CloneVersionFn: testutil.CloneVersionResult(4), + CreateBackendFn: createBackendOK, + GetPackageFn: getPackageOk, + GetServiceDetailsFn: getServiceDetailsWasm, + GetServiceFn: getServiceOK, + ListDomainsFn: listDomainsOk, + ListVersionsFn: testutil.ListVersions, + UpdatePackageFn: updatePackageOk, + }, + httpClientRes: []*http.Response{ + mock.NewHTTPResponse(http.StatusNoContent, nil, nil), + mock.NewHTTPResponse(http.StatusOK, nil, io.NopCloser(strings.NewReader("success"))), + }, + httpClientErr: []error{ + nil, + nil, + }, + manifest: ` + name = "package" + manifest_version = 2 + language = "rust" + + [setup.config_stores.example] + description = "My first dictionary" + [setup.config_stores.example.items.foo] + value = "my default value for foo" + description = "a good description about foo" + [setup.config_stores.example.items.bar] + value = "my default value for bar" + description = "a good description about bar" + `, + wantOutput: []string{ + "Uploading package", + "Activating service", + "SUCCESS: Deployed package (service 123, version 4)", + }, + dontWantOutput: []string{ + "Configuring dictionary 'dict_a'", + "Create a config store key called 'foo'", + "Create a config store key called 'bar'", + "Creating config store 'example'", + "Creating config store item 'foo'", + "Creating config store item 'bar'", + }, + }, + { + name: "success with setup.config_stores configuration and no existing service", + args: args("compute deploy --token 123"), + api: mock.API{ + ActivateVersionFn: activateVersionOk, + CreateBackendFn: createBackendOK, + CreateConfigStoreFn: createConfigStoreOK, + CreateDomainFn: createDomainOK, + CreateResourceFn: createResourceOK, + CreateServiceFn: createServiceOK, + GetPackageFn: getPackageOk, + GetServiceDetailsFn: getServiceDetailsWasm, + GetServiceFn: getServiceOK, + ListConfigStoresFn: listConfigStoresEmpty, + ListDomainsFn: listDomainsOk, + ListVersionsFn: testutil.ListVersions, + UpdateConfigStoreItemFn: updateConfigStoreItemOK, + UpdatePackageFn: updatePackageOk, + }, + httpClientRes: []*http.Response{ + mock.NewHTTPResponse(http.StatusNoContent, nil, nil), + mock.NewHTTPResponse(http.StatusOK, nil, io.NopCloser(strings.NewReader("success"))), + }, + httpClientErr: []error{ + nil, + nil, + }, + manifest: ` + name = "package" + manifest_version = 2 + language = "rust" + + [setup.config_stores.example] + description = "My first store" + [setup.config_stores.example.items.foo] + value = "my default value for foo" + description = "a good description about foo" + [setup.config_stores.example.items.bar] + value = "my default value for bar" + description = "a good description about bar" + `, + stdin: []string{ + "Y", // when prompted to create a new service + }, + wantOutput: []string{ + "Configuring config store 'example'", + "My first store", + "Create a config store key called 'foo'", + "my default value for foo", + "Create a config store key called 'bar'", + "my default value for bar", + "Creating config store 'example'", + "Creating config store item 'foo'", + "Creating config store item 'bar'", + "Uploading package", + "Activating service", + "SUCCESS: Deployed package (service 12345, version 1)", + }, + }, + { + name: "success with setup.config_stores configuration and no existing service and a conflicting store name", + args: args("compute deploy --token 123"), + api: mock.API{ + ActivateVersionFn: activateVersionOk, + CreateBackendFn: createBackendOK, + CreateConfigStoreFn: createConfigStoreOK, + CreateDomainFn: createDomainOK, + CreateResourceFn: createResourceOK, + CreateServiceFn: createServiceOK, + GetConfigStoreFn: getConfigStoreOk, + GetPackageFn: getPackageOk, + GetServiceDetailsFn: getServiceDetailsWasm, + GetServiceFn: getServiceOK, + ListConfigStoresFn: listConfigStoresOk, + ListDomainsFn: listDomainsOk, + ListVersionsFn: testutil.ListVersions, + UpdateConfigStoreItemFn: updateConfigStoreItemOK, + UpdatePackageFn: updatePackageOk, + }, + httpClientRes: []*http.Response{ + mock.NewHTTPResponse(http.StatusNoContent, nil, nil), + mock.NewHTTPResponse(http.StatusOK, nil, io.NopCloser(strings.NewReader("success"))), + }, + httpClientErr: []error{ + nil, + nil, + }, + manifest: ` + name = "package" + manifest_version = 2 + language = "rust" + + [setup.config_stores.example] + description = "My first store" + [setup.config_stores.example.items.foo] + value = "my default value for foo" + description = "a good description about foo" + [setup.config_stores.example.items.bar] + value = "my default value for bar" + description = "a good description about bar" + `, + stdin: []string{ + "Y", // when prompted to create a new service + }, + wantOutput: []string{ + "WARNING: A Config Store called 'example' already exists", + "Retrieving existing Config Store 'example'", + "Configuring config store 'example'", + "My first store", + "Create a config store key called 'foo'", + "my default value for foo", + "Create a config store key called 'bar'", + "my default value for bar", + "Creating config store item 'foo'", + "Creating config store item 'bar'", + "Uploading package", + "Activating service", + "SUCCESS: Deployed package (service 12345, version 1)", + }, + }, + { + name: "success with setup.config_stores configuration and no existing service and --non-interactive", + args: args("compute deploy --non-interactive --token 123"), + api: mock.API{ + ActivateVersionFn: activateVersionOk, + CreateBackendFn: createBackendOK, + CreateConfigStoreFn: createConfigStoreOK, + CreateDomainFn: createDomainOK, + CreateResourceFn: createResourceOK, + CreateServiceFn: createServiceOK, + GetPackageFn: getPackageOk, + GetServiceDetailsFn: getServiceDetailsWasm, + GetServiceFn: getServiceOK, + ListConfigStoresFn: listConfigStoresEmpty, + ListDomainsFn: listDomainsOk, + ListVersionsFn: testutil.ListVersions, + UpdateConfigStoreItemFn: updateConfigStoreItemOK, + UpdatePackageFn: updatePackageOk, + }, + httpClientRes: []*http.Response{ + mock.NewHTTPResponse(http.StatusNoContent, nil, nil), + mock.NewHTTPResponse(http.StatusOK, nil, io.NopCloser(strings.NewReader("success"))), + }, + httpClientErr: []error{ + nil, + nil, + }, + manifest: ` + name = "package" + manifest_version = 2 + language = "rust" + + [setup.config_stores.example] + description = "My first store" + [setup.config_stores.example.items.foo] + value = "my default value for foo" + description = "a good description about foo" + [setup.config_stores.example.items.bar] + value = "my default value for bar" + description = "a good description about bar" + `, + stdin: []string{ + "Y", // when prompted to create a new service + }, + wantOutput: []string{ + "Creating config store 'example'", + "Creating config store item 'foo'", + "Creating config store item 'bar'", + "Uploading package", + "Activating service", + "SUCCESS: Deployed package (service 12345, version 1)", + }, + }, + { + name: "success with setup.config_stores configuration and no existing service and no predefined values", + args: args("compute deploy --token 123"), + api: mock.API{ + ActivateVersionFn: activateVersionOk, + CreateBackendFn: createBackendOK, + CreateConfigStoreFn: createConfigStoreOK, + CreateDomainFn: createDomainOK, + CreateResourceFn: createResourceOK, + CreateServiceFn: createServiceOK, + GetPackageFn: getPackageOk, + GetServiceDetailsFn: getServiceDetailsWasm, + GetServiceFn: getServiceOK, + ListConfigStoresFn: listConfigStoresEmpty, + ListDomainsFn: listDomainsOk, + ListVersionsFn: testutil.ListVersions, + UpdateConfigStoreItemFn: updateConfigStoreItemOK, + UpdatePackageFn: updatePackageOk, + }, + httpClientRes: []*http.Response{ + mock.NewHTTPResponse(http.StatusNoContent, nil, nil), + mock.NewHTTPResponse(http.StatusOK, nil, io.NopCloser(strings.NewReader("success"))), + }, + httpClientErr: []error{ + nil, + nil, + }, + manifest: ` + name = "package" + manifest_version = 2 + language = "rust" + + [setup.config_stores.example] + [setup.config_stores.example.items.foo] + [setup.config_stores.example.items.bar] + `, + stdin: []string{ + "Y", // when prompted to create a new service + }, + wantOutput: []string{ + "Configuring config store 'example'", + "Create a config store key called 'foo'", + "Create a config store key called 'bar'", + "Creating config store 'example'", + "Creating config store item 'foo'", + "Creating config store item 'bar'", + "Uploading package", + "Activating service", + "SUCCESS: Deployed package (service 12345, version 1)", + }, + // The following are predefined values for the `description` and `value` + // fields from the prior setup.config_stores tests that we expect to not + // be present in the stdout/stderr as the [setup.config_stores] + // configuration does not define them. + dontWantOutput: []string{ + "My first store", + "my default value for foo", + "my default value for bar", + }, + }, + { + name: "success with setup.log_entries configuration and existing service", + args: args("compute deploy --service-id 123 --token 123"), + api: mock.API{ + ActivateVersionFn: activateVersionOk, + CloneVersionFn: testutil.CloneVersionResult(4), + CreateBackendFn: createBackendOK, + GetPackageFn: getPackageOk, + GetServiceDetailsFn: getServiceDetailsWasm, + GetServiceFn: getServiceOK, + ListDomainsFn: listDomainsOk, + ListVersionsFn: testutil.ListVersions, + UpdatePackageFn: updatePackageOk, + }, + httpClientRes: []*http.Response{ + mock.NewHTTPResponse(http.StatusNoContent, nil, nil), + mock.NewHTTPResponse(http.StatusOK, nil, io.NopCloser(strings.NewReader("success"))), + }, + httpClientErr: []error{ + nil, + nil, + }, + manifest: ` + name = "package" + manifest_version = 2 + language = "rust" + + [setup.log_endpoints.foo] + provider = "BigQuery" + `, + wantOutput: []string{ + "Uploading package", + "Activating service", + "SUCCESS: Deployed package (service 123, version 4)", + }, + dontWantOutput: []string{ + "The package code requires the following log endpoints to be created.", + "Name: foo", + "Provider: BigQuery", + "Refer to the help documentation for each provider (if no provider shown, then select your own):", + "fastly logging create --help", + }, + }, + { + name: "success with setup.log_entries configuration and no existing service", + args: args("compute deploy --token 123"), + api: mock.API{ + ActivateVersionFn: activateVersionOk, + CreateBackendFn: createBackendOK, + CreateDictionaryFn: createDictionaryOK, + CreateDictionaryItemFn: createDictionaryItemOK, + CreateDomainFn: createDomainOK, + CreateServiceFn: createServiceOK, + GetPackageFn: getPackageOk, + GetServiceDetailsFn: getServiceDetailsWasm, + GetServiceFn: getServiceOK, + ListDomainsFn: listDomainsOk, + ListVersionsFn: testutil.ListVersions, + UpdatePackageFn: updatePackageOk, + }, + httpClientRes: []*http.Response{ + mock.NewHTTPResponse(http.StatusNoContent, nil, nil), + mock.NewHTTPResponse(http.StatusOK, nil, io.NopCloser(strings.NewReader("success"))), + }, + httpClientErr: []error{ + nil, + nil, + }, + manifest: ` + name = "package" + manifest_version = 2 + language = "rust" + + [setup.log_endpoints.foo] + provider = "BigQuery" + `, + stdin: []string{ + "Y", // when prompted to create a new service + }, + wantOutput: []string{ + "The package code requires the following log endpoints to be created.", + "Name: foo", + "Provider: BigQuery", + "Refer to the help documentation for each provider (if no provider shown, then select your own):", + "fastly logging create --help", + "Uploading package", + "Activating service", + "SUCCESS: Deployed package (service 12345, version 1)", + }, + }, + { + name: "success with setup.log_entries configuration and no existing service and no provider defined", + args: args("compute deploy --token 123"), + api: mock.API{ + ActivateVersionFn: activateVersionOk, + CreateBackendFn: createBackendOK, + CreateDictionaryFn: createDictionaryOK, + CreateDictionaryItemFn: createDictionaryItemOK, + CreateDomainFn: createDomainOK, + CreateServiceFn: createServiceOK, + GetPackageFn: getPackageOk, + GetServiceDetailsFn: getServiceDetailsWasm, + GetServiceFn: getServiceOK, + ListDomainsFn: listDomainsOk, + ListVersionsFn: testutil.ListVersions, + UpdatePackageFn: updatePackageOk, + }, + httpClientRes: []*http.Response{ + mock.NewHTTPResponse(http.StatusNoContent, nil, nil), + mock.NewHTTPResponse(http.StatusOK, nil, io.NopCloser(strings.NewReader("success"))), + }, + httpClientErr: []error{ + nil, + nil, + }, + manifest: ` + name = "package" + manifest_version = 2 + language = "rust" + + [setup.log_endpoints.foo] + `, + stdin: []string{ + "Y", // when prompted to create a new service + }, + wantOutput: []string{ + "The package code requires the following log endpoints to be created.", + "Name: foo", + "Refer to the help documentation for each provider (if no provider shown, then select your own):", + "fastly logging create --help", + "Uploading package", + "Activating service", + "SUCCESS: Deployed package (service 12345, version 1)", + }, + dontWantOutput: []string{ + "Provider: BigQuery", + }, + }, + { + name: "success with setup.log_entries configuration and no existing service, but a provider defined", + args: args("compute deploy --token 123"), + api: mock.API{ + ActivateVersionFn: activateVersionOk, + CreateBackendFn: createBackendOK, + CreateDictionaryFn: createDictionaryOK, + CreateDictionaryItemFn: createDictionaryItemOK, + CreateDomainFn: createDomainOK, + CreateServiceFn: createServiceOK, + GetPackageFn: getPackageOk, + GetServiceDetailsFn: getServiceDetailsWasm, + GetServiceFn: getServiceOK, + ListDomainsFn: listDomainsOk, + ListVersionsFn: testutil.ListVersions, + UpdatePackageFn: updatePackageOk, + }, + httpClientRes: []*http.Response{ + mock.NewHTTPResponse(http.StatusNoContent, nil, nil), + mock.NewHTTPResponse(http.StatusOK, nil, io.NopCloser(strings.NewReader("success"))), + }, + httpClientErr: []error{ + nil, + nil, + }, + manifest: ` + name = "package" + manifest_version = 2 + language = "rust" + + [setup.log_endpoints.foo] + provider = "BigQuery" + `, + stdin: []string{ + "Y", // when prompted to create a new service + }, + wantOutput: []string{ + "The package code requires the following log endpoints to be created.", + "Name: foo", + "Provider: BigQuery", + "Refer to the help documentation for each provider (if no provider shown, then select your own):", + "fastly logging create --help", + "Uploading package", + "Activating service", + "SUCCESS: Deployed package (service 12345, version 1)", + }, + }, + // NOTE: The following test validates [setup] only works for a new service. + { + name: "success with setup.kv_stores configuration and existing service", + args: args("compute deploy --service-id 123 --token 123"), + api: mock.API{ + ActivateVersionFn: activateVersionOk, + CloneVersionFn: testutil.CloneVersionResult(4), + CreateBackendFn: createBackendOK, + GetPackageFn: getPackageOk, + GetServiceDetailsFn: getServiceDetailsWasm, + GetServiceFn: getServiceOK, + ListDomainsFn: listDomainsOk, + ListVersionsFn: testutil.ListVersions, + UpdatePackageFn: updatePackageOk, + }, + httpClientRes: []*http.Response{ + mock.NewHTTPResponse(http.StatusNoContent, nil, nil), + mock.NewHTTPResponse(http.StatusOK, nil, io.NopCloser(strings.NewReader("success"))), + }, + httpClientErr: []error{ + nil, + nil, + }, + manifest: ` + name = "package" + manifest_version = 2 + language = "rust" + + [setup.kv_stores.store_one] + description = "My first KV Store" + [setup.kv_stores.store_one.items.foo] + value = "my default value for foo" + description = "a good description about foo" + [setup.kv_stores.store_one.items.bar] + value = "my default value for bar" + description = "a good description about bar" + `, + wantOutput: []string{ + "Uploading package", + "Activating service", + "SUCCESS: Deployed package (service 123, version 4)", + }, + dontWantOutput: []string{ + "Configuring KV Store 'store_one'", + "Create a KV Store key called 'foo'", + "Create a KV Store key called 'bar'", + "Creating KV Store 'store_one'", + "Creating KV Store key 'foo'", + "Creating KV Store key 'bar'", + }, + }, + { + name: "success with setup.kv_stores configuration and no existing service plus use of file and existing store", + args: args("compute deploy --token 123"), + api: mock.API{ + ActivateVersionFn: activateVersionOk, + CreateBackendFn: createBackendOK, + CreateDomainFn: createDomainOK, + CreateResourceFn: createResourceOK, + CreateServiceFn: createServiceOK, + GetKVStoreFn: getKVStoreOk, + GetPackageFn: getPackageOk, + GetServiceDetailsFn: getServiceDetailsWasm, + GetServiceFn: getServiceOK, + InsertKVStoreKeyFn: createKVStoreItemOK, + ListDomainsFn: listDomainsOk, + ListKVStoresFn: listKVStoresOk, + ListVersionsFn: testutil.ListVersions, + UpdatePackageFn: updatePackageOk, + }, + httpClientRes: []*http.Response{ + mock.NewHTTPResponse(http.StatusNoContent, nil, nil), + mock.NewHTTPResponse(http.StatusOK, nil, io.NopCloser(strings.NewReader("success"))), + }, + httpClientErr: []error{ + nil, + nil, + }, + manifest: ` + name = "package" + manifest_version = 2 + language = "rust" + + [setup.kv_stores.store_one] + description = "My first KV Store" + [setup.kv_stores.store_one.items.foo] + value = "my default value for foo" + description = "a good description about foo" + [setup.kv_stores.store_one.items.bar] + value = "my default value for bar" + description = "a good description about bar" + [setup.kv_stores.store_one.items.baz] + file = "./kv_store_one_baz.txt" + description = "a file containing the data for this key" + `, + stdin: []string{ + "Y", // when prompted to create a new service + }, + wantOutput: []string{ + "WARNING: A KV Store called 'store_one' already exists", + "Retrieving existing KV Store 'store_one'", + "Create a KV Store key called 'foo'", + "Create a KV Store key called 'bar'", + "Create a KV Store key called 'baz'", + "Creating KV Store key 'foo'", + "Creating KV Store key 'bar'", + "Creating KV Store key 'baz'", + "Uploading package", + "Activating service", + "SUCCESS: Deployed package (service 12345, version 1)", + }, + }, + { + name: "error with setup.kv_stores configuration and no existing service with file and value on same key", + args: args("compute deploy --token 123"), + api: mock.API{ + CreateBackendFn: createBackendOK, + CreateDomainFn: createDomainOK, + CreateKVStoreFn: createKVStoreOK, + CreateResourceFn: createResourceOK, + CreateServiceFn: createServiceOK, + DeleteServiceFn: deleteServiceOK, + GetPackageFn: getPackageOk, + GetServiceDetailsFn: getServiceDetailsWasm, + GetServiceFn: getServiceOK, + InsertKVStoreKeyFn: createKVStoreItemOK, + ListDomainsFn: listDomainsOk, + ListKVStoresFn: listKVStoresEmpty, + ListVersionsFn: testutil.ListVersions, + }, + httpClientRes: []*http.Response{ + mock.NewHTTPResponse(http.StatusNoContent, nil, nil), + mock.NewHTTPResponse(http.StatusOK, nil, io.NopCloser(strings.NewReader("success"))), + }, + httpClientErr: []error{ + nil, + nil, + }, + manifest: ` + name = "package" + manifest_version = 2 + language = "rust" + + [setup.kv_stores.store_one] + description = "My first KV Store" + [setup.kv_stores.store_one.items.baz] + value = "some_value" + file = "./kv_store_one_baz.txt" + description = "a file containing the data for this key" + `, + stdin: []string{ + "Y", // when prompted to create a new service + }, + wantOutput: []string{ + "Configuring KV Store 'store_one'", + }, + wantError: "invalid config: both 'value' and 'file' were set", + }, + { + name: "success with setup.kv_stores configuration and no existing service and --non-interactive", + args: args("compute deploy --non-interactive --token 123"), + api: mock.API{ + ActivateVersionFn: activateVersionOk, + CreateBackendFn: createBackendOK, + CreateDomainFn: createDomainOK, + CreateKVStoreFn: createKVStoreOK, + CreateResourceFn: createResourceOK, + CreateServiceFn: createServiceOK, + GetPackageFn: getPackageOk, + GetServiceDetailsFn: getServiceDetailsWasm, + GetServiceFn: getServiceOK, + InsertKVStoreKeyFn: createKVStoreItemOK, + ListDomainsFn: listDomainsOk, + ListKVStoresFn: listKVStoresEmpty, + ListVersionsFn: testutil.ListVersions, + UpdatePackageFn: updatePackageOk, + }, + httpClientRes: []*http.Response{ + mock.NewHTTPResponse(http.StatusNoContent, nil, nil), + mock.NewHTTPResponse(http.StatusOK, nil, io.NopCloser(strings.NewReader("success"))), + }, + httpClientErr: []error{ + nil, + nil, + }, + manifest: ` + name = "package" + manifest_version = 2 + language = "rust" + + [setup.kv_stores.store_one] + description = "My first KV Store" + [setup.kv_stores.store_one.items.foo] + value = "my default value for foo" + description = "a good description about foo" + [setup.kv_stores.store_one.items.bar] + value = "my default value for bar" + description = "a good description about bar" + `, + stdin: []string{ + "Y", // when prompted to create a new service + }, + wantOutput: []string{ + "Creating KV Store 'store_one'", + "Creating KV Store key 'foo'", + "Creating KV Store key 'bar'", + "Uploading package", + "Activating service", + "SUCCESS: Deployed package (service 12345, version 1)", + }, + }, + { + name: "success with setup.kv_stores configuration and no existing service and no predefined values", + args: args("compute deploy --token 123"), + api: mock.API{ + ActivateVersionFn: activateVersionOk, + CreateBackendFn: createBackendOK, + CreateDomainFn: createDomainOK, + CreateKVStoreFn: createKVStoreOK, + CreateResourceFn: createResourceOK, + CreateServiceFn: createServiceOK, + GetPackageFn: getPackageOk, + GetServiceDetailsFn: getServiceDetailsWasm, + GetServiceFn: getServiceOK, + InsertKVStoreKeyFn: createKVStoreItemOK, + ListDomainsFn: listDomainsOk, + ListKVStoresFn: listKVStoresEmpty, + ListVersionsFn: testutil.ListVersions, + UpdatePackageFn: updatePackageOk, + }, + httpClientRes: []*http.Response{ + mock.NewHTTPResponse(http.StatusNoContent, nil, nil), + mock.NewHTTPResponse(http.StatusOK, nil, io.NopCloser(strings.NewReader("success"))), + }, + httpClientErr: []error{ + nil, + nil, + }, + manifest: ` + name = "package" + manifest_version = 2 + language = "rust" + + [setup.kv_stores.store_one] + [setup.kv_stores.store_one.items.foo] + [setup.kv_stores.store_one.items.bar] + `, + stdin: []string{ + "Y", // when prompted to create a new service + }, + wantOutput: []string{ + "Configuring KV Store 'store_one'", + "Create a KV Store key called 'foo'", + "Create a KV Store key called 'bar'", + "Creating KV Store 'store_one'", + "Creating KV Store key 'foo'", + "Creating KV Store key 'bar'", + "Uploading package", + "Activating service", + "SUCCESS: Deployed package (service 12345, version 1)", + }, + // The following are predefined values for the `description` and `value` + // fields from the prior setup.dictionaries tests that we expect to not + // be present in the stdout/stderr as the [setup/dictionaries] + // configuration does not define them. + dontWantOutput: []string{ + "My first KV Store", + "my default value for foo", + "my default value for bar", + }, + }, + // NOTE: The following test validates [setup] only works for a new service. + { + name: "success with setup.secret_stores configuration and existing service", + args: args("compute deploy --service-id 123 --token 123"), + api: mock.API{ + ActivateVersionFn: activateVersionOk, + CloneVersionFn: testutil.CloneVersionResult(4), + CreateBackendFn: createBackendOK, + GetPackageFn: getPackageOk, + GetServiceDetailsFn: getServiceDetailsWasm, + GetServiceFn: getServiceOK, + ListDomainsFn: listDomainsOk, + ListVersionsFn: testutil.ListVersions, + UpdatePackageFn: updatePackageOk, + }, + httpClientRes: []*http.Response{ + mock.NewHTTPResponse(http.StatusNoContent, nil, nil), + mock.NewHTTPResponse(http.StatusOK, nil, io.NopCloser(strings.NewReader("success"))), + }, + httpClientErr: []error{ + nil, + nil, + }, + manifest: ` + name = "package" + manifest_version = 2 + language = "rust" + + [setup.secret_stores.store_one] + description = "My first Secret Store" + [setup.secret_stores.store_one.entries.foo] + description = "a good description about foo" + [setup.secret_stores.store_one.entries.bar] + description = "a good description about bar" + `, + wantOutput: []string{ + "Uploading package", + "Activating service", + "SUCCESS: Deployed package (service 123, version 4)", + }, + dontWantOutput: []string{ + "Configuring Secret Store 'store_one'", + "Create a Secret Store entry called 'foo'", + "Create a Secret Store entry called 'bar'", + "Creating Secret Store 'store_one'", + "Creating Secret Store entry 'foo'", + "Creating Secret Store entry 'bar'", + }, + }, + { + name: "success with setup.secret_stores configuration and no existing service but an existing store", + args: args("compute deploy --token 123"), + api: mock.API{ + ActivateVersionFn: activateVersionOk, + CreateBackendFn: createBackendOK, + CreateDomainFn: createDomainOK, + CreateResourceFn: createResourceOK, + CreateSecretFn: createSecretOk, + CreateServiceFn: createServiceOK, + GetPackageFn: getPackageOk, + GetSecretStoreFn: getSecretStoreOk, + GetServiceDetailsFn: getServiceDetailsWasm, + GetServiceFn: getServiceOK, + ListDomainsFn: listDomainsOk, + ListSecretStoresFn: listSecretStoresOk, + ListVersionsFn: testutil.ListVersions, + UpdatePackageFn: updatePackageOk, + }, + httpClientRes: []*http.Response{ + mock.NewHTTPResponse(http.StatusNoContent, nil, nil), + mock.NewHTTPResponse(http.StatusOK, nil, io.NopCloser(strings.NewReader("success"))), + }, + httpClientErr: []error{ + nil, + nil, + }, + manifest: ` + name = "package" + manifest_version = 2 + language = "rust" + + [setup.secret_stores.store_one] + description = "My first Secret Store" + [setup.secret_stores.store_one.entries.foo] + description = "a good description about foo" + [setup.secret_stores.store_one.entries.bar] + description = "a good description about bar" + [setup.secret_stores.store_one.entries.baz] + description = "a file containing the data for this entry" + `, + stdin: []string{ + "Y", // when prompted to create a new service + "", // leave blank for service name prompt + "", // leave blank for backend prompt + "", // leave blank for using existing store prompt + "my_secret", // when prompted to add a secret for foo (this can't be empty) + "my_secret", // when prompted to add a secret for bar (this can't be empty) + "my_secret", // when prompted to add a secret for baz (this can't be empty) + }, + wantOutput: []string{ + "WARNING: A Secret Store called 'store_one' already exists", + "Retrieving existing Secret Store 'store_one'", + "Create a Secret Store entry called 'foo'", + "Create a Secret Store entry called 'bar'", + "Create a Secret Store entry called 'baz'", + "Creating Secret Store entry 'foo'", + "Creating Secret Store entry 'bar'", + "Creating Secret Store entry 'baz'", + "Uploading package", + "Activating service", + "SUCCESS: Deployed package (service 12345, version 1)", + }, + }, + { + name: "success with setup.secret_stores configuration and no existing service and no predefined values", + args: args("compute deploy --token 123"), + api: mock.API{ + ActivateVersionFn: activateVersionOk, + CreateBackendFn: createBackendOK, + CreateDomainFn: createDomainOK, + CreateResourceFn: createResourceOK, + CreateSecretFn: createSecretOk, + CreateSecretStoreFn: createSecretStoreOk, + CreateServiceFn: createServiceOK, + GetPackageFn: getPackageOk, + GetServiceDetailsFn: getServiceDetailsWasm, + GetServiceFn: getServiceOK, + ListDomainsFn: listDomainsOk, + ListSecretStoresFn: listSecretStoresEmpty, + ListVersionsFn: testutil.ListVersions, + UpdatePackageFn: updatePackageOk, + }, + httpClientRes: []*http.Response{ + mock.NewHTTPResponse(http.StatusNoContent, nil, nil), + mock.NewHTTPResponse(http.StatusOK, nil, io.NopCloser(strings.NewReader("success"))), + }, + httpClientErr: []error{ + nil, + nil, + }, + manifest: ` + name = "package" + manifest_version = 2 + language = "rust" + + [setup.secret_stores.store_one] + [setup.secret_stores.store_one.entries.foo] + [setup.secret_stores.store_one.entries.bar] + `, + stdin: []string{ + "Y", // when prompted to create a new service + "", // leave blank for service name prompt + "", // leave blank for backend prompt + "my_secret", // when prompted to add a secret for foo (this can't be empty) + "my_secret", // when prompted to add a secret for bar (this can't be empty) + }, + wantOutput: []string{ + "Configuring Secret Store 'store_one'", + "Create a Secret Store entry called 'foo'", + "Create a Secret Store entry called 'bar'", + "Creating Secret Store 'store_one'", + "Creating Secret Store entry 'foo'", + "Creating Secret Store entry 'bar'", + "Uploading package", + "Activating service", + "SUCCESS: Deployed package (service 12345, version 1)", + }, + // The following are predefined values for the `description` and `value` + // fields from the prior setup.dictionaries tests that we expect to not + // be present in the stdout/stderr as the [setup/dictionaries] + // configuration does not define them. + dontWantOutput: []string{ + "My first Secret Store", + "my default value for foo", + "my default value for bar", + }, + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(testcase.name, func(t *testing.T) { + // Because the manifest can be mutated on each test scenario, we recreate + // the file each time. + manifestContent := `manifest_version = 2 + name = "package" + ` + if testcase.manifest != "" { + manifestContent = testcase.manifest + } + if err := os.WriteFile(filepath.Join(rootdir, manifest.Filename), []byte(manifestContent), 0o600); err != nil { + t.Fatal(err) + } + + // For any test scenario that expects no manifest to exist, then instead + // of deleting the manifest and having to recreate it, we'll simply + // rename it, and then rename it back once the specific test scenario has + // finished running. + if testcase.noManifest { + old := filepath.Join(rootdir, manifest.Filename) + tmp := filepath.Join(rootdir, manifest.Filename+"Tmp") + if err := os.Rename(old, tmp); err != nil { + t.Fatal(err) + } + defer func() { + if err := os.Rename(tmp, old); err != nil { + t.Fatal(err) + } + }() + } + + var stdout threadsafe.Buffer + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + + if testcase.httpClientRes != nil || testcase.httpClientErr != nil { + opts.HTTPClient = mock.HTMLClient(testcase.httpClientRes, testcase.httpClientErr) + } + + if testcase.reduceSizeLimit { + compute.MaxPackageSize = 1000000 // 1mb (our test package should above this) + } else { + // As multiple test scenarios run within a single environment instance + // we need to ensure each scenario resets the package variable. + compute.MaxPackageSize = originalPackageSizeLimit + } + + if len(testcase.stdin) > 1 { + // To handle multiple prompt input from the user we need to do some + // coordination around io pipes to mimic the required user behaviour. + stdin, prompt := io.Pipe() + opts.Input = stdin + + // Wait for user input and write it to the prompt + inputc := make(chan string) + go func() { + for input := range inputc { + fmt.Fprintln(prompt, input) + } + }() + + // We need a channel so we wait for `run()` to complete + done := make(chan bool) + + // Call `app.Run()` and wait for response + go func() { + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + return opts, nil + } + err = app.Run(testcase.args, nil) + done <- true + }() + + // User provides input + // + // NOTE: Must provide as much input as is expected to be waited on by `run()`. + // For example, if `run()` calls `input()` twice, then provide two messages. + // Otherwise the select statement will trigger the timeout error. + for _, input := range testcase.stdin { + inputc <- input + } + + select { + case <-done: + // Wait for app.Run() to finish + case <-time.After(10 * time.Second): + t.Log(stdout.String()) + t.Fatalf("unexpected timeout waiting for mocked prompt inputs to be processed") + } + } else { + stdin := "" + if len(testcase.stdin) > 0 { + stdin = testcase.stdin[0] + } + opts.Input = strings.NewReader(stdin) + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + return opts, nil + } + err = app.Run(testcase.args, nil) + } + + t.Log(stdout.String()) + + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertRemediationErrorContains(t, err, testcase.wantRemediationError) + + for _, s := range testcase.wantOutput { + testutil.AssertStringContains(t, stdout.String(), s) + } + + for _, s := range testcase.dontWantOutput { + testutil.AssertStringDoesntContain(t, stdout.String(), s) + } + }) + } +} + +func TestDeploy_ActivateBeacon(t *testing.T) { + // We're going to chdir to a deploy environment, + // so save the PWD to return to, afterwards. + pwd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + + // Create test environment + rootdir := testutil.NewEnv(testutil.EnvOpts{ + T: t, + Copy: []testutil.FileIO{ + { + Src: filepath.Join("testdata", "deploy", "pkg", "package.tar.gz"), + Dst: filepath.Join("pkg", "package.tar.gz"), + }, + }, + Write: []testutil.FileIO{ + { + Src: "This is my data for the KV Store 'store_one' baz field.", + Dst: "kv_store_one_baz.txt", + }, + }, + }) + defer os.RemoveAll(rootdir) + + // Before running the test, chdir into the build environment. + // When we're done, chdir back to our original location. + // This is so we can reliably copy the testdata/ fixtures. + if err := os.Chdir(rootdir); err != nil { + t.Fatal(err) + } + defer func() { + _ = os.Chdir(pwd) + }() + + stdout := threadsafe.Buffer{} + args := testutil.SplitArgs("compute deploy --auto-yes --non-interactive") + recordingHTTP := &mock.HTTPClient{ + Responses: []*http.Response{ + // the body is closed by beacon.Notify + //nolint: bodyclose + mock.NewHTTPResponse(http.StatusNoContent, nil, nil), + }, + Errors: []error{ + nil, + }, + Index: -1, + SaveRequests: true, + } + + manifestContent := ` + name = "package" + manifest_version = 2 + language = "rust" + ` + + if err := os.WriteFile(filepath.Join(rootdir, manifest.Filename), []byte(manifestContent), 0o600); err != nil { + t.Fatal(err) + } + + opts := testutil.MockGlobalData(args, &stdout) + opts.HTTPClient = recordingHTTP + opts.APIClientFactory = mock.APIClient(mock.API{ + ActivateVersionFn: func(*fastly.ActivateVersionInput) (*fastly.Version, error) { + return nil, testutil.Err + }, + CloneVersionFn: testutil.CloneVersionResult(4), + CreateBackendFn: createBackendOK, + CreateServiceFn: createServiceOK, + DeleteServiceFn: deleteServiceOK, + GetPackageFn: getPackageOk, + GetServiceDetailsFn: getServiceDetailsWasm, + GetServiceFn: getServiceOK, + ListDomainsFn: listDomainsOk, + ListVersionsFn: testutil.ListVersions, + UpdatePackageFn: updatePackageOk, + }) + + app.Init = func(_ []string, stdin io.Reader) (*global.Data, error) { + opts.Input = stdin + return opts, nil + } + + err = app.Run(args, nil) + + testutil.AssertErrorContains(t, err, "error activating version:") + testutil.AssertLength(t, 1, recordingHTTP.Requests) + + beaconReq := recordingHTTP.Requests[0] + testutil.AssertEqual(t, "fastly-notification-relay.edgecompute.app", beaconReq.URL.Hostname()) +} + +func createServiceOK(i *fastly.CreateServiceInput) (*fastly.Service, error) { + return &fastly.Service{ + ServiceID: fastly.ToPointer("12345"), + Name: i.Name, + Type: i.Type, + }, nil +} + +func createServiceError(*fastly.CreateServiceInput) (*fastly.Service, error) { + return nil, testutil.Err +} + +// NOTE: We don't return testutil.Err but a very specific error message so that +// the Deploy logic will drop into a nested logic block. +func createServiceErrorNoTrial(*fastly.CreateServiceInput) (*fastly.Service, error) { + return nil, fmt.Errorf("Valid values for 'type' are: 'vcl'") +} + +func getCurrentUser() (*fastly.User, error) { + return &fastly.User{ + CustomerID: fastly.ToPointer("abc"), + }, nil +} + +func getCurrentUserError() (*fastly.User, error) { + return nil, testutil.Err +} + +func deleteServiceOK(_ *fastly.DeleteServiceInput) error { + return nil +} + +func createDomainError(_ *fastly.CreateDomainInput) (*fastly.Domain, error) { + return nil, testutil.Err +} + +func deleteDomainOK(_ *fastly.DeleteDomainInput) error { + return nil +} + +func createBackendError(_ *fastly.CreateBackendInput) (*fastly.Backend, error) { + return nil, testutil.Err +} + +func deleteBackendOK(_ *fastly.DeleteBackendInput) error { + return nil +} + +func getPackageIdentical(i *fastly.GetPackageInput) (*fastly.Package, error) { + return &fastly.Package{ + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Metadata: &fastly.PackageMetadata{ + FilesHash: fastly.ToPointer("d8786807216a37608ecd0bc2357c86f883faad89043141f0a147f2c186ce0212333d31229399c131539205908f5cf0884ea64552782544ff9b27416cd5b996b2"), + HashSum: fastly.ToPointer("bf634ccf8be5c8417cf562466ece47ea61056ddeb07273a3d861e8ad757ed3577bc182006d04093c301467cadfd2b1805eedebd1e7cfa0404c723680f2dbc01e"), + }, + }, nil +} + +func activateVersionError(_ *fastly.ActivateVersionInput) (*fastly.Version, error) { + return nil, testutil.Err +} + +func listDomainsError(_ *fastly.ListDomainsInput) ([]*fastly.Domain, error) { + return nil, testutil.Err +} + +func listDomainsNone(_ *fastly.ListDomainsInput) ([]*fastly.Domain, error) { + return []*fastly.Domain{}, nil +} diff --git a/pkg/commands/compute/dir.go b/pkg/commands/compute/dir.go new file mode 100644 index 000000000..46609f706 --- /dev/null +++ b/pkg/commands/compute/dir.go @@ -0,0 +1,39 @@ +package compute + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/fastly/cli/pkg/manifest" +) + +// EnvManifestMsg informs the user that an environment manifest is being used. +const EnvManifestMsg = "Using the '%s' environment manifest (it will be packaged up as %s)\n\n" + +// ProjectDirMsg informs the user that we've changed the project directory. +const ProjectDirMsg = "Changed project directory to '%s'\n\n" + +// EnvironmentManifest returns the relevant manifest filename, taking into +// account the user passing an --env flag. +func EnvironmentManifest(env string) (manifestFilename string) { + manifestFilename = manifest.Filename + if env != "" { + manifestFilename = fmt.Sprintf("fastly.%s.toml", env) + } + return manifestFilename +} + +// ChangeProjectDirectory moves into `dir` and returns its absolute path. +func ChangeProjectDirectory(dir string) (projectDirectory string, err error) { + if dir != "" { + projectDirectory, err = filepath.Abs(dir) + if err != nil { + return "", fmt.Errorf("failed to construct absolute path to directory '%s': %w", dir, err) + } + if err := os.Chdir(projectDirectory); err != nil { + return "", fmt.Errorf("failed to change working directory to '%s': %w", projectDirectory, err) + } + } + return projectDirectory, nil +} diff --git a/pkg/commands/compute/doc.go b/pkg/commands/compute/doc.go new file mode 100644 index 000000000..dcdbe0ec4 --- /dev/null +++ b/pkg/commands/compute/doc.go @@ -0,0 +1,2 @@ +// Package compute contains commands to manage Compute packages. +package compute diff --git a/pkg/commands/compute/hashfiles.go b/pkg/commands/compute/hashfiles.go new file mode 100644 index 000000000..fad16636b --- /dev/null +++ b/pkg/commands/compute/hashfiles.go @@ -0,0 +1,214 @@ +package compute + +import ( + "archive/tar" + "bytes" + "crypto/sha512" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "sort" + + "github.com/kennygrant/sanitize" + "github.com/mholt/archiver/v3" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/text" +) + +// MaxPackageSize represents the max package size that can be uploaded to the +// Fastly Package API endpoint. +// +// NOTE: This is variable not a constant for the sake of test manipulations. +// https://www.fastly.com/documentation/guides/compute#limitations-and-constraints +var MaxPackageSize int64 = 100000000 // 100MB in bytes + +// HashFilesCommand produces a deployable artifact from files on the local disk. +type HashFilesCommand struct { + argparser.Base + + // Build fields + dir argparser.OptionalString + env argparser.OptionalString + includeSrc argparser.OptionalBool + lang argparser.OptionalString + metadataDisable argparser.OptionalBool + metadataFilterEnvVars argparser.OptionalString + metadataShow argparser.OptionalBool + packageName argparser.OptionalString + timeout argparser.OptionalInt + + buildCmd *BuildCommand + Package string + SkipBuild bool +} + +// NewHashFilesCommand returns a usable command registered under the parent. +func NewHashFilesCommand(parent argparser.Registerer, g *global.Data, build *BuildCommand) *HashFilesCommand { + var c HashFilesCommand + c.buildCmd = build + c.Globals = g + c.CmdClause = parent.Command("hash-files", "Generate a SHA512 digest from the contents of the Compute package") + c.CmdClause.Flag("dir", "Project directory to build (default: current directory)").Short('C').Action(c.dir.Set).StringVar(&c.dir.Value) + c.CmdClause.Flag("env", "The manifest environment config to use (e.g. 'stage' will attempt to read 'fastly.stage.toml')").Action(c.env.Set).StringVar(&c.env.Value) + c.CmdClause.Flag("include-source", "Include source code in built package").Action(c.includeSrc.Set).BoolVar(&c.includeSrc.Value) + c.CmdClause.Flag("language", "Language type").Action(c.lang.Set).StringVar(&c.lang.Value) + c.CmdClause.Flag("metadata-disable", "Disable Wasm binary metadata annotations").Action(c.metadataDisable.Set).BoolVar(&c.metadataDisable.Value) + c.CmdClause.Flag("metadata-filter-envvars", "Redact specified environment variables from [scripts.env_vars] using comma-separated list").Action(c.metadataFilterEnvVars.Set).StringVar(&c.metadataFilterEnvVars.Value) + c.CmdClause.Flag("metadata-show", "Inspect the Wasm binary metadata").Action(c.metadataShow.Set).BoolVar(&c.metadataShow.Value) + c.CmdClause.Flag("package", "Path to a package tar.gz").Short('p').StringVar(&c.Package) + c.CmdClause.Flag("package-name", "Package name").Action(c.packageName.Set).StringVar(&c.packageName.Value) + c.CmdClause.Flag("skip-build", "Skip the build step").BoolVar(&c.SkipBuild) + c.CmdClause.Flag("timeout", "Timeout, in seconds, for the build compilation step").Action(c.timeout.Set).IntVar(&c.timeout.Value) + + return &c +} + +// Exec implements the command interface. +func (c *HashFilesCommand) Exec(in io.Reader, out io.Writer) (err error) { + if !c.SkipBuild && c.Package == "" { + err = c.Build(in, out) + if err != nil { + return err + } + if c.Globals.Verbose() { + text.Break(out) + } + } + + var pkgPath string + + if c.Package == "" { + manifestFilename := EnvironmentManifest(c.env.Value) + wd, err := os.Getwd() + if err != nil { + return fmt.Errorf("failed to get current working directory: %w", err) + } + defer func() { + _ = os.Chdir(wd) + }() + manifestPath := filepath.Join(wd, manifestFilename) + + projectDir, err := ChangeProjectDirectory(c.dir.Value) + if err != nil { + return err + } + if projectDir != "" { + if c.Globals.Verbose() { + text.Info(out, ProjectDirMsg, projectDir) + } + manifestPath = filepath.Join(projectDir, manifestFilename) + } + + if projectDir != "" || c.env.WasSet { + err = c.Globals.Manifest.File.Read(manifestPath) + } else { + err = c.Globals.Manifest.File.ReadError() + } + if err != nil { + if errors.Is(err, os.ErrNotExist) { + err = fsterr.ErrReadingManifest + } + c.Globals.ErrLog.Add(err) + return err + } + + projectName, source := c.Globals.Manifest.Name() + if source == manifest.SourceUndefined { + return fsterr.ErrReadingManifest + } + pkgPath = filepath.Join(projectDir, "pkg", fmt.Sprintf("%s.tar.gz", sanitize.BaseName(projectName))) + } else { + pkgPath, err = filepath.Abs(c.Package) + if err != nil { + return fmt.Errorf("failed to locate package path '%s': %w", c.Package, err) + } + } + + hash, err := getFilesHash(pkgPath) + if err != nil { + return err + } + + text.Output(out, hash) + return nil +} + +// Build constructs and executes the build logic. +func (c *HashFilesCommand) Build(in io.Reader, out io.Writer) error { + output := out + if !c.Globals.Verbose() { + output = io.Discard + } + if c.dir.WasSet { + c.buildCmd.Flags.Dir = c.dir.Value + } + if c.env.WasSet { + c.buildCmd.Flags.Env = c.env.Value + } + if c.includeSrc.WasSet { + c.buildCmd.Flags.IncludeSrc = c.includeSrc.Value + } + if c.lang.WasSet { + c.buildCmd.Flags.Lang = c.lang.Value + } + if c.packageName.WasSet { + c.buildCmd.Flags.PackageName = c.packageName.Value + } + if c.timeout.WasSet { + c.buildCmd.Flags.Timeout = c.timeout.Value + } + if c.metadataDisable.WasSet { + c.buildCmd.MetadataDisable = c.metadataDisable.Value + } + if c.metadataFilterEnvVars.WasSet { + c.buildCmd.MetadataFilterEnvVars = c.metadataFilterEnvVars.Value + } + if c.metadataShow.WasSet { + c.buildCmd.MetadataShow = c.metadataShow.Value + } + return c.buildCmd.Exec(in, output) +} + +// getFilesHash returns a hash of all the files in the package in sorted filename order. +func getFilesHash(pkgPath string) (string, error) { + contents := make(map[string]*bytes.Buffer) + + if err := packageFiles(pkgPath, func(f archiver.File) error { + // We want the full path here and not f.Name(), which is only the + // filename. + // + // This is safe to do - we already verified it in packageFiles(). + header, ok := f.Header.(*tar.Header) + if !ok { + return errors.New("failed to convert file type into *tar.Header") + } + entry := header.Name + contents[entry] = &bytes.Buffer{} + if _, err := io.Copy(contents[entry], f); err != nil { + return fmt.Errorf("error reading %s: %w", entry, err) + } + return nil + }); err != nil { + return "", err + } + + keys := make([]string, 0, len(contents)) + for k := range contents { + keys = append(keys, k) + } + sort.Strings(keys) + + h := sha512.New() + for _, entry := range keys { + if _, err := io.Copy(h, contents[entry]); err != nil { + return "", fmt.Errorf("failed to generate hash from package files: %w", err) + } + } + return fmt.Sprintf("%x", h.Sum(nil)), nil +} diff --git a/pkg/commands/compute/hashsum.go b/pkg/commands/compute/hashsum.go new file mode 100644 index 000000000..746571967 --- /dev/null +++ b/pkg/commands/compute/hashsum.go @@ -0,0 +1,204 @@ +package compute + +import ( + "crypto/sha512" + "errors" + "fmt" + "io" + "os" + "path/filepath" + + "github.com/kennygrant/sanitize" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/text" +) + +// HashsumCommand produces a deployable artifact from files on the local disk. +type HashsumCommand struct { + argparser.Base + + // Build fields + dir argparser.OptionalString + env argparser.OptionalString + includeSrc argparser.OptionalBool + lang argparser.OptionalString + metadataDisable argparser.OptionalBool + metadataFilterEnvVars argparser.OptionalString + metadataShow argparser.OptionalBool + packageName argparser.OptionalString + timeout argparser.OptionalInt + + buildCmd *BuildCommand + PackagePath string + SkipBuild bool +} + +// NewHashsumCommand returns a usable command registered under the parent. +// Deprecated: Use NewHashFilesCommand instead. +func NewHashsumCommand(parent argparser.Registerer, g *global.Data, build *BuildCommand) *HashsumCommand { + var c HashsumCommand + c.buildCmd = build + c.Globals = g + c.CmdClause = parent.Command("hashsum", "Generate a SHA512 digest from a Compute package").Hidden() + c.CmdClause.Flag("dir", "Project directory to build (default: current directory)").Short('C').Action(c.dir.Set).StringVar(&c.dir.Value) + c.CmdClause.Flag("env", "The manifest environment config to use (e.g. 'stage' will attempt to read 'fastly.stage.toml')").Action(c.env.Set).StringVar(&c.env.Value) + c.CmdClause.Flag("include-source", "Include source code in built package").Action(c.includeSrc.Set).BoolVar(&c.includeSrc.Value) + c.CmdClause.Flag("language", "Language type").Action(c.lang.Set).StringVar(&c.lang.Value) + c.CmdClause.Flag("metadata-disable", "Disable Wasm binary metadata annotations").Action(c.metadataDisable.Set).BoolVar(&c.metadataDisable.Value) + c.CmdClause.Flag("metadata-filter-envvars", "Redact specified environment variables from [scripts.env_vars] using comma-separated list").Action(c.metadataFilterEnvVars.Set).StringVar(&c.metadataFilterEnvVars.Value) + c.CmdClause.Flag("metadata-show", "Inspect the Wasm binary metadata").Action(c.metadataShow.Set).BoolVar(&c.metadataShow.Value) + c.CmdClause.Flag("package", "Path to a package tar.gz").Short('p').StringVar(&c.PackagePath) + c.CmdClause.Flag("package-name", "Package name").Action(c.packageName.Set).StringVar(&c.packageName.Value) + c.CmdClause.Flag("skip-build", "Skip the build step").BoolVar(&c.SkipBuild) + c.CmdClause.Flag("timeout", "Timeout, in seconds, for the build compilation step").Action(c.timeout.Set).IntVar(&c.timeout.Value) + + return &c +} + +// Exec implements the command interface. +func (c *HashsumCommand) Exec(in io.Reader, out io.Writer) (err error) { + if !c.Globals.Flags.Quiet { + // FIXME: Remove `hashsum` subcommand before v11.0.0 is released. + text.Warning(out, "This command is deprecated. Use `fastly compute hash-files` instead.") + } + + // No point in building a package if the user provides a package path. + if !c.SkipBuild && c.PackagePath == "" { + err = c.Build(in, out) + if err != nil { + return err + } + if !c.Globals.Flags.Quiet { + text.Break(out) + } + } + + pkgPath := c.PackagePath + if pkgPath == "" { + manifestFilename := EnvironmentManifest(c.env.Value) + wd, err := os.Getwd() + if err != nil { + return fmt.Errorf("failed to get current working directory: %w", err) + } + defer func() { + _ = os.Chdir(wd) + }() + manifestPath := filepath.Join(wd, manifestFilename) + + projectDir, err := ChangeProjectDirectory(c.dir.Value) + if err != nil { + return err + } + if projectDir != "" { + if c.Globals.Verbose() { + text.Info(out, ProjectDirMsg, projectDir) + } + manifestPath = filepath.Join(projectDir, manifestFilename) + } + + if projectDir != "" || c.env.WasSet { + err = c.Globals.Manifest.File.Read(manifestPath) + } else { + err = c.Globals.Manifest.File.ReadError() + } + if err != nil { + if errors.Is(err, os.ErrNotExist) { + err = fsterr.ErrReadingManifest + } + c.Globals.ErrLog.Add(err) + return err + } + + projectName, source := c.Globals.Manifest.Name() + if source == manifest.SourceUndefined { + return fsterr.ErrReadingManifest + } + pkgPath = filepath.Join(projectDir, "pkg", fmt.Sprintf("%s.tar.gz", sanitize.BaseName(projectName))) + } + + err = validatePackage(pkgPath) + if err != nil { + var skipBuildMsg string + if c.SkipBuild { + skipBuildMsg = " avoid using --skip-build, or" + } + return fsterr.RemediationError{ + Inner: fmt.Errorf("failed to validate package: %w", err), + Remediation: fmt.Sprintf("Run `fastly compute build` to produce a Compute package, alternatively%s use the --package flag to reference a package outside of the current project.", skipBuildMsg), + } + } + + hashSum, err := getHashSum(pkgPath) + if err != nil { + return err + } + + text.Output(out, hashSum) + return nil +} + +// Build constructs and executes the build logic. +func (c *HashsumCommand) Build(in io.Reader, out io.Writer) error { + output := out + if !c.Globals.Verbose() && !c.metadataShow.WasSet { + output = io.Discard + } else { + text.Break(out) + } + if c.dir.WasSet { + c.buildCmd.Flags.Dir = c.dir.Value + } + if c.env.WasSet { + c.buildCmd.Flags.Env = c.env.Value + } + if c.includeSrc.WasSet { + c.buildCmd.Flags.IncludeSrc = c.includeSrc.Value + } + if c.lang.WasSet { + c.buildCmd.Flags.Lang = c.lang.Value + } + if c.packageName.WasSet { + c.buildCmd.Flags.PackageName = c.packageName.Value + } + if c.timeout.WasSet { + c.buildCmd.Flags.Timeout = c.timeout.Value + } + if c.metadataDisable.WasSet { + c.buildCmd.MetadataDisable = c.metadataDisable.Value + } + if c.metadataFilterEnvVars.WasSet { + c.buildCmd.MetadataFilterEnvVars = c.metadataFilterEnvVars.Value + } + if c.metadataShow.WasSet { + c.buildCmd.MetadataShow = c.metadataShow.Value + } + return c.buildCmd.Exec(in, output) +} + +// getHashSum returns a hash of the package. +func getHashSum(pkg string) (string, error) { + // gosec flagged this: + // G304 (CWE-22): Potential file inclusion via variable + // Disabling as we trust the source of the filepath variable. + /* #nosec */ + f, err := os.Open(pkg) + if err != nil { + return "", err + } + + h := sha512.New() + if _, err := io.Copy(h, f); err != nil { + _ = f.Close() + return "", err + } + + if err = f.Close(); err != nil { + return "", err + } + + return fmt.Sprintf("%x", h.Sum(nil)), nil +} diff --git a/pkg/commands/compute/init.go b/pkg/commands/compute/init.go new file mode 100644 index 000000000..b79e1ee6d --- /dev/null +++ b/pkg/commands/compute/init.go @@ -0,0 +1,1326 @@ +package compute + +import ( + "crypto/rand" + "errors" + "fmt" + "io" + "io/fs" + "net/http" + "net/url" + "os" + "os/exec" + "path/filepath" + "regexp" + "strconv" + "strings" + "time" + + cp "github.com/otiai10/copy" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/config" + "github.com/fastly/cli/pkg/debug" + fsterr "github.com/fastly/cli/pkg/errors" + fstexec "github.com/fastly/cli/pkg/exec" + "github.com/fastly/cli/pkg/file" + "github.com/fastly/cli/pkg/filesystem" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/internal/beacon" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/profile" + "github.com/fastly/cli/pkg/text" +) + +var ( + gitRepositoryRegEx = regexp.MustCompile(`((git|ssh|http(s)?)|(git@[\w\.]+))(:(//)?)([\w\.@\:/\-~]+)(\.git)?(/)?`) + fastlyOrgRegEx = regexp.MustCompile(`^https:\/\/github\.com\/fastly`) + fastlyFileIgnoreListRegEx = regexp.MustCompile(`\.github|LICENSE|SECURITY\.md|CHANGELOG\.md|screenshot\.png`) +) + +// InitCommand initializes a Compute project package on the local machine. +type InitCommand struct { + argparser.Base + + // CloneFrom is the value of the --from flag. + // NOTE: CloneFrom is public so that we can check to see if we need + // a token (to use --from=service-id) or not (to use a git + // repository). + CloneFrom string + + branch string + dir string + language string + tag string +} + +// Languages is a list of supported language options. +var Languages = []string{"rust", "javascript", "go", "other"} + +// NewInitCommand returns a usable command registered under the parent. +func NewInitCommand(parent argparser.Registerer, g *global.Data) *InitCommand { + var c InitCommand + c.Globals = g + + c.CmdClause = parent.Command("init", "Initialize a new Compute package locally") + c.CmdClause.Flag("author", "Author(s) of the package").Short('a').StringsVar(&g.Manifest.File.Authors) + c.CmdClause.Flag("branch", "Git branch name to clone from package template repository").Hidden().StringVar(&c.branch) + c.CmdClause.Flag("directory", "Destination to write the new package, defaulting to the current directory").Short('p').StringVar(&c.dir) + c.CmdClause.Flag("from", "Local project directory, or Git repository URL, or URL referencing a .zip/.tar.gz file, containing a package template, or an existing service ID created from a starter kit").Short('f').StringVar(&c.CloneFrom) + c.CmdClause.Flag("language", "Language of the package").Short('l').HintOptions(Languages...).EnumVar(&c.language, Languages...) + c.CmdClause.Flag("tag", "Git tag name to clone from package template repository").Hidden().StringVar(&c.tag) + + return &c +} + +// Exec implements the command interface. +func (c *InitCommand) Exec(in io.Reader, out io.Writer) (err error) { + var ( + introContext string + isExistingService bool + ) + if c.CloneFrom != "" { + isExistingService = text.IsFastlyID(c.CloneFrom) + if !isExistingService { + introContext = " (using --from to locate package template)" + } + } + + if isExistingService { + text.Output(out, "Initializing Compute project from service %s.\n\n", c.CloneFrom) + } else { + text.Output(out, "Creating a new Compute project%s.\n\n", introContext) + } + text.Output(out, "Press ^C at any time to quit.") + + if c.CloneFrom != "" && !isExistingService && c.language == "" { + text.Warning(out, "\nWhen using the --from flag, the project language cannot be inferred. Please either use the --language flag to explicitly set the language or ensure the project's fastly.toml sets a valid language.") + } + + text.Break(out) + cont, notEmpty, err := c.VerifyDirectory(in, out) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + if !cont { + text.Break(out) + return fsterr.RemediationError{ + Inner: fmt.Errorf("project directory not empty"), + Remediation: fsterr.ExistingDirRemediation, + } + } + + defer func(errLog fsterr.LogInterface) { + if err != nil { + errLog.Add(err) + } + }(c.Globals.ErrLog) + + wd, err := os.Getwd() + if err != nil { + c.Globals.ErrLog.Add(err) + return fmt.Errorf("error determining current directory: %w", err) + } + + mf := c.Globals.Manifest.File + if c.Globals.Flags.Quiet { + mf.SetQuiet(true) + } + if c.dir == "" && !mf.Exists() && c.Globals.Verbose() { + text.Info(out, "--directory not specified, using current directory\n\n") + c.dir = wd + } + + spinner, err := text.NewSpinner(out) + if err != nil { + return err + } + + dst, err := c.VerifyDestination(spinner) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Directory": c.dir, + }) + return err + } + c.dir = dst + + if notEmpty { + text.Break(out) + } + err = spinner.Process("Validating directory permissions", validateDirectoryPermissions(dst)) + if err != nil { + return err + } + + // Assign the default profile email if available. + email := "" + if _, p := profile.Default(c.Globals.Config.Profiles); p != nil { + email = p.Email + } + + var ( + name string + desc string + authors []string + ) + if !isExistingService { + name, desc, authors, err = c.PromptOrReturn(email, in, out) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Description": desc, + "Directory": c.dir, + }) + return err + } + } + + languages := NewLanguages(c.Globals.Config.StarterKits) + + var language *Language + + if c.language == "" && c.CloneFrom == "" && c.Globals.Manifest.File.Language == "" { + language, err = c.PromptForLanguage(languages, in, out) + if err != nil { + return err + } + } + + // NOTE: The --language flag is an EnumVar, meaning it's already validated. + if c.language != "" || mf.Language != "" { + l := c.language + if c.language == "" { + l = mf.Language + } + for _, recognisedLanguage := range languages { + if strings.EqualFold(l, recognisedLanguage.Name) { + language = recognisedLanguage + } + } + } + + var from, branch, tag string + + // If the user doesn't tell us where to clone from, or there is already a + // fastly.toml manifest, or the language they selected was "other" (meaning + // they're bringing their own project code), then we'll prompt the user to + // select a starter kit project. + triggerStarterKitPrompt := c.CloneFrom == "" && !mf.Exists() && language.Name != "other" + if triggerStarterKitPrompt { + from, branch, tag, err = c.PromptForStarterKit(language.StarterKits, in, out) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "From": c.CloneFrom, + "Branch": c.branch, + "Tag": c.tag, + "Manifest Exist": false, + }) + return err + } + c.CloneFrom = from + } + + defer func() { + if triggerStarterKitPrompt || !isExistingService { + return + } + + evt := beacon.Event{ + Name: "init", + } + if err != nil { + evt.Status = beacon.StatusFail + } else { + evt.Status = beacon.StatusSuccess + } + + bErr := beacon.Notify(c.Globals, c.CloneFrom, evt) + if bErr != nil { + c.Globals.ErrLog.Add(bErr) + } + }() + + // There are three situations in which we might fetch something + // here. We might fetch a template if: + // + // 1. --from flag is set to a template repository, or + // 2. user selects starter kit when prompted + // + // Or we fetch an existing, deployed package if + // + // 3. --from flag is set to a serviceID + // + // We don't fetch if the user has indicated their language of choice is + // "other" because this means they intend on handling the compilation of code + // that isn't natively supported by the platform. + if c.CloneFrom != "" { + if !isExistingService { + err = c.FetchPackageTemplate(branch, tag, file.Archives, spinner, out) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "From": from, + "Branch": branch, + "Tag": tag, + "Directory": c.dir, + }) + return err + } + } else { + var ( + serviceDetails *fastly.ServiceDetail + pack *fastly.Package + serviceVersion int + ) + err = spinner.Process("Fetching service details", func(_ *text.SpinnerWrapper) error { + serviceDetails, err = c.Globals.APIClient.GetServiceDetails(&fastly.GetServiceInput{ + ServiceID: c.CloneFrom, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "From": c.CloneFrom, + "Directory": c.dir, + }) + if hErr, ok := err.(*fastly.HTTPError); ok && hErr.IsNotFound() { + return fmt.Errorf("the service %s could not be found", c.CloneFrom) + } + return err + } + + if fastly.ToValue(serviceDetails.Type) != "wasm" { + return fmt.Errorf("service %s is not a Compute service (type is %s)", c.CloneFrom, fastly.ToValue(serviceDetails.Type)) + } + + if serviceDetails.ActiveVersion != nil { + serviceVersion = fastly.ToValue(serviceDetails.ActiveVersion.Number) + pack, err = c.Globals.APIClient.GetPackage(&fastly.GetPackageInput{ + ServiceID: c.CloneFrom, + ServiceVersion: serviceVersion, + }) + if err != nil { + return err + } + } else { + for i := len(serviceDetails.Versions) - 1; i >= 0; i-- { + serviceVersion = fastly.ToValue(serviceDetails.Versions[i].Number) + pack, err = c.Globals.APIClient.GetPackage(&fastly.GetPackageInput{ + ServiceID: c.CloneFrom, + ServiceVersion: serviceVersion, + }) + if err != nil { + if hErr, ok := err.(*fastly.HTTPError); ok { + if hErr.IsNotFound() { + continue + } + } + return err + } + if pack != nil { + break + } + } + } + + // were not able to find any service versions with an + // existing package + if pack == nil { + return fmt.Errorf("unable to find any version of service %s with an existing package", c.CloneFrom) + } + + return nil + }) + if err != nil { + return err + } + + if pack.Metadata != nil { + clonedFrom := fastly.ToValue(pack.Metadata.ClonedFrom) + if serviceVersion > 1 { + text.Info(out, "\nService has active versions, not fetching starter kit source\n\n") + } else if gitRepositoryRegEx.MatchString(clonedFrom) { + err = spinner.Process("Initializing file structure from selected starter kit", func(*text.SpinnerWrapper) error { + err := c.ClonePackageFromEndpoint(clonedFrom, "", "") + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "cloned_from": clonedFrom, + }) + return fmt.Errorf("could not fetch original source code: %w", err) + } + return nil + }) + if err != nil { + return err + } + } + + if pack.Metadata.Name != nil { + name = *pack.Metadata.Name + } + + if name == "" { + name = *serviceDetails.Name + } + + if pack.Metadata.Description != nil { + desc = *pack.Metadata.Description + } + + if desc == "" { + desc = fastly.ToValue(serviceDetails.Comment) + } + + authors = append(authors, pack.Metadata.Authors...) + mf.Language = fastly.ToValue(pack.Metadata.Language) + } + + mf.Name = name + mf.ServiceID = *pack.ServiceID + mf.Description = desc + // mf.Profile = profileName + mf.Authors = authors + + mp := filepath.Join(c.dir, manifest.Filename) + err = mf.Write(mp) + if err != nil { + return fmt.Errorf("error creating fastly.toml: %w", err) + } + } + } + + // If the user was prompted to fill the name/desc/authors/lang, then we insert + // a line break so the following spinner instances have spacing. But only if + // the starter kit wasn't prompted for as that already handles spacing. + if (mf.Name == "" || mf.Description == "" || mf.Language == "" || len(mf.Authors) == 0) && !triggerStarterKitPrompt { + text.Break(out) + } + + mf, err = c.UpdateManifest(mf, spinner, name, desc, authors, language) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Directory": c.dir, + "Description": desc, + "Language": language, + }) + return err + } + + language, err = c.InitializeLanguage(spinner, language, languages, mf.Language, wd) + if err != nil { + c.Globals.ErrLog.Add(err) + return fmt.Errorf("error initializing package: %w", err) + } + + var md manifest.Data + err = md.File.Read(manifest.Filename) + if err != nil { + return fmt.Errorf("failed to read manifest after initialisation: %w", err) + } + + postInit := md.File.Scripts.PostInit + if postInit != "" { + if !c.Globals.Flags.AutoYes && !c.Globals.Flags.NonInteractive { + msg := fmt.Sprintf(CustomPostScriptMessage, "init", manifest.Filename) + err := promptForPostInitContinue(msg, postInit, out, in) + if err != nil { + if errors.Is(err, fsterr.ErrPostInitStopped) { + displayInitOutput(mf.Name, dst, language.Name, out) + return nil + } + return err + } + } + + if c.Globals.Flags.Verbose && len(md.File.Scripts.EnvVars) > 0 { + text.Description(out, "Environment variables set", strings.Join(md.File.Scripts.EnvVars, " ")) + } + + // If we're in verbose mode, the command output is shown. + // So in that case we don't want to have a spinner as it'll interweave output. + // In non-verbose mode we have a spinner running while the command execution is happening. + msg := "Running [scripts.post_init]..." + if !c.Globals.Flags.Verbose { + err = spinner.Start() + if err != nil { + return err + } + spinner.Message(msg) + } + + s := Shell{} + command, args := s.Build(postInit) + // gosec flagged this: + // G204 (CWE-78): Subprocess launched with function call as argument or cmd arguments + // Disabling as we require the user to provide this command. + // #nosec + // nosemgrep: go.lang.security.audit.dangerous-exec-command.dangerous-exec-command + err := fstexec.Command(fstexec.CommandOpts{ + Args: args, + Command: command, + Env: md.File.Scripts.EnvVars, + ErrLog: c.Globals.ErrLog, + Output: out, + Spinner: spinner, + SpinnerMessage: msg, + Timeout: 0, // zero indicates no timeout + Verbose: c.Globals.Flags.Verbose, + }) + if err != nil { + // In verbose mode we'll have the failure status AFTER the error output. + // But we can't just call StopFailMessage() without first starting the spinner. + if c.Globals.Flags.Verbose { + text.Break(out) + spinErr := spinner.Start() + if spinErr != nil { + return fmt.Errorf(text.SpinnerErrWrapper, spinErr, err) + } + spinner.Message(msg + "...") + spinner.StopFailMessage(msg) + if spinErr := spinner.StopFail(); spinErr != nil { + return fmt.Errorf(text.SpinnerErrWrapper, spinErr, err) + } + } + return err + } + + // In verbose mode we'll have the failure status AFTER the error output. + // But we can't just call StopMessage() without first starting the spinner. + if c.Globals.Flags.Verbose { + err = spinner.Start() + if err != nil { + return err + } + spinner.Message(msg + "...") + text.Break(out) + } + + spinner.StopMessage(msg) + err = spinner.Stop() + if err != nil { + return err + } + } + + displayInitOutput(mf.Name, dst, language.Name, out) + return nil +} + +// VerifyDirectory indicates if the user wants to continue with the execution +// flow when presented with a prompt that suggests the current directory isn't +// empty. +func (c *InitCommand) VerifyDirectory(in io.Reader, out io.Writer) (cont, notEmpty bool, err error) { + flags := c.Globals.Flags + dir := c.dir + + if dir == "" { + dir = "." + } + dir, err = filepath.Abs(dir) + if err != nil { + return false, false, err + } + + files, err := os.ReadDir(dir) + if err != nil { + return false, false, err + } + + if strings.Contains(dir, " ") && !flags.Quiet { + text.Warning(out, "Your project path contains spaces. In some cases this can result in issues with your installed language toolchain, e.g. `npm`. Consider removing any spaces.\n\n") + } + + if len(files) > 0 && !flags.AutoYes && !flags.NonInteractive { + label := fmt.Sprintf("The current directory isn't empty. Are you sure you want to initialize a Compute project in %s? [y/N] ", dir) + result, err := text.AskYesNo(out, label, in) + if err != nil { + return false, true, err + } + return result, true, nil + } + + return true, false, nil +} + +// VerifyDestination checks the provided path exists and is a directory. +// +// NOTE: For validating user permissions it will create a temporary file within +// the directory and then remove it before returning the absolute path to the +// directory itself. +func (c *InitCommand) VerifyDestination(spinner text.Spinner) (dst string, err error) { + dst, err = filepath.Abs(c.dir) + if err != nil { + return "", err + } + fi, err := os.Stat(dst) + if err != nil && !errors.Is(err, fs.ErrNotExist) { + return dst, fmt.Errorf("couldn't verify package directory: %w", err) // generic error + } + if err == nil && !fi.IsDir() { + return dst, fmt.Errorf("package destination is not a directory") // specific problem + } + if err != nil && errors.Is(err, fs.ErrNotExist) { // normal-ish case + err := spinner.Process(fmt.Sprintf("Creating %s", dst), func(_ *text.SpinnerWrapper) error { + if err := os.MkdirAll(dst, 0o700); err != nil { + return fmt.Errorf("error creating package destination: %w", err) + } + return nil + }) + if err != nil { + return "", err + } + } + return dst, nil +} + +func validateDirectoryPermissions(dst string) text.SpinnerProcess { + return func(_ *text.SpinnerWrapper) error { + tmpname := make([]byte, 16) + n, err := rand.Read(tmpname) + if err != nil { + return fmt.Errorf("error generating random filename: %w", err) + } + if n != 16 { + return fmt.Errorf("failed to generate enough entropy (%d/%d)", n, 16) + } + + // gosec flagged this: + // G304 (CWE-22): Potential file inclusion via variable + // + // Disabling as the input is determined by our own package. + // #nosec + f, err := os.Create(filepath.Join(dst, fmt.Sprintf("tmp_%x", tmpname))) + if err != nil { + return fmt.Errorf("error creating file in package destination: %w", err) + } + + if err := f.Close(); err != nil { + return fmt.Errorf("error closing file in package destination: %w", err) + } + + if err := os.Remove(f.Name()); err != nil { + return fmt.Errorf("error removing file in package destination: %w", err) + } + return nil + } +} + +// PromptOrReturn will prompt the user for information missing from the +// fastly.toml manifest file, otherwise if it already exists then the value is +// returned as is. +func (c *InitCommand) PromptOrReturn(email string, in io.Reader, out io.Writer) (name, description string, authors []string, err error) { + flags := c.Globals.Flags + name, _ = c.Globals.Manifest.Name() + description, _ = c.Globals.Manifest.Description() + authors, _ = c.Globals.Manifest.Authors() + + if name == "" && !flags.AcceptDefaults && !flags.NonInteractive { + text.Break(out) + } + name, err = c.PromptPackageName(flags, name, in, out) + if err != nil { + return "", description, authors, err + } + + if description == "" && !flags.AcceptDefaults && !flags.NonInteractive { + text.Break(out) + } + description, err = promptPackageDescription(flags, description, in, out) + if err != nil { + return name, "", authors, err + } + + if len(authors) == 0 && !flags.AcceptDefaults && !flags.NonInteractive { + text.Break(out) + } + authors, err = promptPackageAuthors(flags, authors, email, in, out) + if err != nil { + return name, description, []string{}, err + } + + return name, description, authors, nil +} + +// PromptPackageName prompts the user for a package name unless already defined either +// via the corresponding CLI flag or the manifest file. +// +// It will use a default of the current directory path if no value provided by +// the user via the prompt. +func (c *InitCommand) PromptPackageName(flags global.Flags, name string, in io.Reader, out io.Writer) (string, error) { + defaultName := filepath.Base(c.dir) + + if name == "" && (flags.AcceptDefaults || flags.NonInteractive) { + return defaultName, nil + } + + if name == "" { + var err error + name, err = text.Input(out, fmt.Sprintf("Name: [%s] ", defaultName), in) + if err != nil { + return "", fmt.Errorf("error reading input: %w", err) + } + if name == "" { + name = defaultName + } + } + + return name, nil +} + +// promptPackageDescription prompts the user for a package description unless already +// defined either via the corresponding CLI flag or the manifest file. +func promptPackageDescription(flags global.Flags, desc string, in io.Reader, out io.Writer) (string, error) { + if desc == "" && (flags.AcceptDefaults || flags.NonInteractive) { + return desc, nil + } + + if desc == "" { + var err error + + desc, err = text.Input(out, "Description: ", in) + if err != nil { + return "", fmt.Errorf("error reading input: %w", err) + } + } + + return desc, nil +} + +// promptPackageAuthors prompts the user for a package name unless already defined +// either via the corresponding CLI flag or the manifest file. +// +// It will use a default of the user's email found within the manifest, if set +// there, otherwise the value will be an empty slice. +// +// FIXME: Handle prompting for multiple authors. +func promptPackageAuthors(flags global.Flags, authors []string, manifestEmail string, in io.Reader, out io.Writer) ([]string, error) { + defaultValue := []string{manifestEmail} + if len(authors) == 0 && (flags.AcceptDefaults || flags.NonInteractive) { + return defaultValue, nil + } + if len(authors) == 0 { + label := "Author (email): " + + if manifestEmail != "" { + label = fmt.Sprintf("%s[%s] ", label, manifestEmail) + } + + author, err := text.Input(out, label, in) + if err != nil { + return []string{}, fmt.Errorf("error reading input %w", err) + } + + if author != "" { + authors = []string{author} + } else { + authors = defaultValue + } + } + + return authors, nil +} + +// PromptForLanguage prompts the user for a package language unless already +// defined either via the corresponding CLI flag or the manifest file. +func (c *InitCommand) PromptForLanguage(languages []*Language, in io.Reader, out io.Writer) (*Language, error) { + var ( + language *Language + option string + err error + ) + flags := c.Globals.Flags + + if !flags.AcceptDefaults && !flags.NonInteractive { + text.Output(out, "\n%s", text.Bold("Language:")) + text.Output(out, "(Find out more about language support at https://www.fastly.com/documentation/guides/compute)") + for i, lang := range languages { + text.Output(out, "[%d] %s", i+1, lang.DisplayName) + } + + text.Break(out) + option, err = text.Input(out, "Choose option: [1] ", in, validateLanguageOption(languages)) + if err != nil { + return nil, fmt.Errorf("reading input %w", err) + } + } + + if option == "" { + option = "1" + } + + i, err := strconv.Atoi(option) + if err != nil { + return nil, fmt.Errorf("failed to identify chosen language") + } + language = languages[i-1] + + return language, nil +} + +// validateLanguageOption ensures the user selects an appropriate value from +// the prompt options displayed. +func validateLanguageOption(languages []*Language) func(string) error { + return func(input string) error { + errMsg := fmt.Errorf("must be a valid option") + if input == "" { + return nil + } + if option, err := strconv.Atoi(input); err == nil { + if option > len(languages) { + return errMsg + } + return nil + } + return errMsg + } +} + +// PromptForStarterKit prompts the user for a package starter kit. +// +// It returns the path to the starter kit, and the corresponding branch/tag. +func (c *InitCommand) PromptForStarterKit(kits []config.StarterKit, in io.Reader, out io.Writer) (from string, branch string, tag string, err error) { + var option string + flags := c.Globals.Flags + + if !flags.AcceptDefaults && !flags.NonInteractive { + text.Output(out, "\n%s", text.Bold("Starter kit:")) + for i, kit := range kits { + fmt.Fprintf(out, "[%d] %s\n", i+1, text.Bold(kit.Name)) + text.Indent(out, 4, "%s\n%s", kit.Description, kit.Path) + } + text.Info(out, "\nFor a complete list of Starter Kits:") + text.Indent(out, 4, "https://www.fastly.com/documentation/solutions/starters") + text.Break(out) + + option, err = text.Input(out, "Choose option or paste git URL: [1] ", in, validateTemplateOptionOrURL(kits)) + if err != nil { + return "", "", "", fmt.Errorf("error reading input: %w", err) + } + text.Break(out) + } + + if option == "" { + option = "1" + } + + var i int + if i, err = strconv.Atoi(option); err == nil { + template := kits[i-1] + return template.Path, template.Branch, template.Tag, nil + } + + return option, "", "", nil +} + +func validateTemplateOptionOrURL(templates []config.StarterKit) func(string) error { + return func(input string) error { + msg := "must be a valid option or git URL" + if input == "" { + return nil + } + if option, err := strconv.Atoi(input); err == nil { + if option > len(templates) { + return errors.New(msg) + } + return nil + } + if !gitRepositoryRegEx.MatchString(input) { + return errors.New(msg) + } + return nil + } +} + +// FetchPackageTemplate will determine if the package code should be fetched +// from GitHub using the git binary to clone the source or a HTTP request that +// uses content-negotiation to determine the type of archive format used. +func (c *InitCommand) FetchPackageTemplate(branch, tag string, archives []file.Archive, spinner text.Spinner, out io.Writer) error { + err := spinner.Start() + if err != nil { + return err + } + text.Break(out) + msg := "Fetching package template" + spinner.Message(msg + "...") + + // If the user has provided a local file path, we'll recursively copy the + // directory to c.dir. + if fi, err := os.Stat(c.CloneFrom); err == nil && fi.IsDir() { + if err := cp.Copy(c.CloneFrom, c.dir); err != nil { + spinner.StopFailMessage(msg) + if spinErr := spinner.StopFail(); spinErr != nil { + return fmt.Errorf(text.SpinnerErrWrapper, spinErr, err) + } + return err + } + spinner.StopMessage(msg) + return spinner.Stop() + } + c.Globals.ErrLog.Add(err) + + // If this isn't a local file path, it should be a URL. + u, err := url.Parse(c.CloneFrom) + if err != nil { + spinner.StopFailMessage(msg) + if spinErr := spinner.StopFail(); spinErr != nil { + return fmt.Errorf(text.SpinnerErrWrapper, spinErr, err) + } + return fmt.Errorf("could not read --from URL: %w", err) + } + + // If given an opaque string, the scheme and host are typically + // empty and the string ends up in u.Path. + if u.Host == "" && u.Scheme == "" { + spinner.StopFailMessage(msg) + if spinErr := spinner.StopFail(); spinErr != nil { + return fmt.Errorf(text.SpinnerErrWrapper, spinErr, err) + } + return fmt.Errorf("--from url seems invalid: %s", c.CloneFrom) + } + + req, err := http.NewRequest(http.MethodGet, u.String(), nil) + if err != nil { + err = fmt.Errorf("failed to construct package request URL: %w", err) + c.Globals.ErrLog.Add(err) + + if gitRepositoryRegEx.MatchString(c.CloneFrom) { + if err := c.ClonePackageFromEndpoint(c.CloneFrom, branch, tag); err != nil { + spinner.StopFailMessage(msg) + if spinErr := spinner.StopFail(); spinErr != nil { + return fmt.Errorf(text.SpinnerErrWrapper, spinErr, err) + } + return err + } + spinner.StopMessage(msg) + return spinner.Stop() + } + + spinner.StopFailMessage(msg) + if spinErr := spinner.StopFail(); spinErr != nil { + return fmt.Errorf(text.SpinnerErrWrapper, spinErr, err) + } + return err + } + + for _, archive := range archives { + for _, mime := range archive.MimeTypes() { + req.Header.Add("Accept", mime) + } + } + + if c.Globals.Flags.Debug { + debug.DumpHTTPRequest(req) + } + res, err := c.Globals.HTTPClient.Do(req) + if c.Globals.Flags.Debug { + debug.DumpHTTPResponse(res) + } + + if err != nil { + err = fmt.Errorf("failed to get package '%s': %w", req.URL.String(), err) + c.Globals.ErrLog.Add(err) + spinner.StopFailMessage(msg) + if spinErr := spinner.StopFail(); spinErr != nil { + return fmt.Errorf(text.SpinnerErrWrapper, spinErr, err) + } + return err + } + defer res.Body.Close() // #nosec G307 + + if res.StatusCode != http.StatusOK { + err := fmt.Errorf("failed to get package '%s': %s", req.URL.String(), res.Status) + c.Globals.ErrLog.Add(err) + spinner.StopFailMessage(msg) + if spinErr := spinner.StopFail(); spinErr != nil { + return fmt.Errorf(text.SpinnerErrWrapper, spinErr, err) + } + return err + } + + tempdir, err := tempDir("package-init-download") + if err != nil { + err = fmt.Errorf("error creating temporary path for package template download: %w", err) + c.Globals.ErrLog.Add(err) + spinner.StopFailMessage(msg) + if spinErr := spinner.StopFail(); spinErr != nil { + return fmt.Errorf(text.SpinnerErrWrapper, spinErr, err) + } + return err + } + defer os.RemoveAll(tempdir) + + filename := filepath.Join( + tempdir, + filepath.Base(c.CloneFrom), + ) + ext := filepath.Ext(filename) + + // gosec flagged this: + // G304 (CWE-22): Potential file inclusion via variable + // + // Disabling as we require a user to configure their own environment. + /* #nosec */ + f, err := os.Create(filename) + if err != nil { + err = fmt.Errorf("failed to create local %s archive: %w", filename, err) + c.Globals.ErrLog.Add(err) + spinner.StopFailMessage(msg) + if spinErr := spinner.StopFail(); spinErr != nil { + return fmt.Errorf(text.SpinnerErrWrapper, spinErr, err) + } + return err + } + + _, err = io.Copy(f, res.Body) + if err != nil { + err = fmt.Errorf("failed to write %s archive to disk: %w", filename, err) + c.Globals.ErrLog.Add(err) + spinner.StopFailMessage(msg) + if spinErr := spinner.StopFail(); spinErr != nil { + return fmt.Errorf(text.SpinnerErrWrapper, spinErr, err) + } + return err + } + + // NOTE: We used to `defer` the closing of the file after its creation but + // realised that this caused issues on Windows as it was unable to rename the + // file as we still have the descriptor `f` open. + if err := f.Close(); err != nil { + c.Globals.ErrLog.Add(err) + } + + var archive file.Archive + +mimes: + for _, mimetype := range res.Header.Values("Content-Type") { + for _, a := range archives { + for _, mime := range a.MimeTypes() { + if mimetype == mime { + archive = a + break mimes + } + } + } + } + + if archive == nil { + for _, a := range archives { + for _, e := range a.Extensions() { + if ext == e { + archive = a + break + } + } + } + } + + if archive != nil { + // Ensure there is a file extension on our filename, otherwise we won't + // know what type of archive format we're dealing with when we come to call + // the archive.Extract() method. + if ext == "" { + filenameWithExt := filename + archive.Extensions()[0] + err := os.Rename(filename, filenameWithExt) + if err != nil { + c.Globals.ErrLog.Add(err) + spinner.StopFailMessage(msg) + if spinErr := spinner.StopFail(); spinErr != nil { + return fmt.Errorf(text.SpinnerErrWrapper, spinErr, err) + } + return err + } + filename = filenameWithExt + } + + archive.SetDestination(c.dir) + archive.SetFilename(filename) + + err = archive.Extract() + if err != nil { + err = fmt.Errorf("failed to extract %s archive content: %w", filename, err) + c.Globals.ErrLog.Add(err) + spinner.StopFailMessage(msg) + if spinErr := spinner.StopFail(); spinErr != nil { + return fmt.Errorf(text.SpinnerErrWrapper, spinErr, err) + } + return err + } + + spinner.StopMessage(msg) + return spinner.Stop() + } + + if err := c.ClonePackageFromEndpoint(c.CloneFrom, branch, tag); err != nil { + spinner.StopFailMessage(msg) + if spinErr := spinner.StopFail(); spinErr != nil { + return fmt.Errorf(text.SpinnerErrWrapper, spinErr, err) + } + return err + } + + spinner.StopMessage(msg) + return spinner.Stop() +} + +// ClonePackageFromEndpoint clones the given repo (from) into a temp directory, +// then copies specific files to the destination directory (path). +func (c *InitCommand) ClonePackageFromEndpoint(from, branch, tag string) error { + _, err := exec.LookPath("git") + if err != nil { + return fsterr.RemediationError{ + Inner: fmt.Errorf("`git` not found in $PATH"), + Remediation: fmt.Sprintf("The Fastly CLI requires a local installation of git. For installation instructions for your operating system see:\n\n\t$ %s", text.Bold("https://git-scm.com/book/en/v2/Getting-Started-Installing-Git")), + } + } + + tempdir, err := tempDir("package-init") + if err != nil { + return fmt.Errorf("error creating temporary path for package template: %w", err) + } + defer os.RemoveAll(tempdir) + + if branch != "" && tag != "" { + return fmt.Errorf("cannot use both git branch and tag name") + } + + args := []string{ + "clone", + "--depth", + "1", + } + var ref string + if branch != "" { + ref = branch + } + if tag != "" { + ref = tag + } + if ref != "" { + args = append(args, "--branch", ref) + } + args = append(args, from, tempdir) + + // gosec flagged this: + // G204 (CWE-78): Subprocess launched with variable + // Disabling as there should be no vulnerability to cloning a remote repo. + /* #nosec */ + command := exec.Command("git", args...) + + // nosemgrep (invalid-usage-of-modified-variable) + stdoutStderr, err := command.CombinedOutput() + if err != nil { + return fmt.Errorf("error fetching package template: %w\n\n%s", err, stdoutStderr) + } + + if err := os.RemoveAll(filepath.Join(tempdir, ".git")); err != nil { + return fmt.Errorf("error removing git metadata from package template: %w", err) + } + + err = filepath.Walk(tempdir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err // abort + } + + if info.IsDir() { + return nil // descend + } + + rel, err := filepath.Rel(tempdir, path) + if err != nil { + return err + } + + // Filter any files we want to ignore in Fastly-owned templates. + if fastlyOrgRegEx.MatchString(from) && fastlyFileIgnoreListRegEx.MatchString(rel) { + return nil + } + + dst := filepath.Join(c.dir, rel) + if err := os.MkdirAll(filepath.Dir(dst), 0o750); err != nil { + return err + } + + return filesystem.CopyFile(path, dst) + }) + if err != nil { + return fmt.Errorf("error copying files from package template: %w", err) + } + + return nil +} + +func tempDir(prefix string) (abspath string, err error) { + abspath, err = filepath.Abs(filepath.Join( + os.TempDir(), + fmt.Sprintf("%s-%d", prefix, time.Now().UnixNano()), + )) + if err != nil { + return "", err + } + + if err = os.MkdirAll(abspath, 0o750); err != nil { + return "", err + } + + return abspath, nil +} + +// UpdateManifest updates the manifest with data acquired from various sources. +// e.g. prompting the user, existing manifest file. +// +// NOTE: The language argument might be nil (if the user passes --from flag). +func (c *InitCommand) UpdateManifest(m manifest.File, spinner text.Spinner, name, desc string, authors []string, language *Language) (manifest.File, error) { + var returnEarly bool + mp := filepath.Join(c.dir, manifest.Filename) + + err := spinner.Process("Reading fastly.toml", func(_ *text.SpinnerWrapper) error { + if err := m.Read(mp); err != nil { + if language != nil { + if language.Name == "other" { + // We create a fastly.toml manifest on behalf of the user if they're + // bringing their own pre-compiled Wasm binary to be packaged. + m.ManifestVersion = manifest.ManifestLatestVersion + m.Name = name + m.Description = desc + m.Authors = authors + m.Language = language.Name + m.ClonedFrom = c.CloneFrom + if err := m.Write(mp); err != nil { + return fmt.Errorf("error saving fastly.toml: %w", err) + } + returnEarly = true + return nil // EXIT updateManifest + } + } + return fmt.Errorf("error reading fastly.toml: %w", err) + } + return nil + }) + if err != nil { + return m, err + } + if returnEarly { + return m, nil + } + + err = spinner.Process(fmt.Sprintf("Setting package name in manifest to %q", name), func(_ *text.SpinnerWrapper) error { + m.Name = name + return nil + }) + if err != nil { + return m, err + } + + var descMsg string + if desc != "" { + descMsg = " to '" + desc + "'" + } + + err = spinner.Process(fmt.Sprintf("Setting description in manifest%s", descMsg), func(_ *text.SpinnerWrapper) error { + // NOTE: We allow an empty description to be set. + m.Description = desc + return nil + }) + if err != nil { + return m, err + } + + if len(authors) > 0 { + err = spinner.Process(fmt.Sprintf("Setting authors in manifest to '%s'", strings.Join(authors, ", ")), func(_ *text.SpinnerWrapper) error { + m.Authors = authors + return nil + }) + if err != nil { + return m, err + } + } + + if language != nil { + err = spinner.Process(fmt.Sprintf("Setting language in manifest to '%s'", language.Name), func(_ *text.SpinnerWrapper) error { + m.Language = language.Name + return nil + }) + if err != nil { + return m, err + } + } + + m.ClonedFrom = c.CloneFrom + + err = spinner.Process("Saving manifest changes", func(_ *text.SpinnerWrapper) error { + if err := m.Write(mp); err != nil { + return fmt.Errorf("error saving fastly.toml: %w", err) + } + return nil + }) + return m, err +} + +// InitializeLanguage for newly cloned package. +func (c *InitCommand) InitializeLanguage(spinner text.Spinner, language *Language, languages []*Language, name, wd string) (*Language, error) { + err := spinner.Process("Initializing package", func(_ *text.SpinnerWrapper) error { + if wd != c.dir { + err := os.Chdir(c.dir) + if err != nil { + return fmt.Errorf("error changing to your project directory: %w", err) + } + } + + // Language will not be set if user provides the --from flag. So we'll check + // the manifest content and ensure what's set there is the language instance + // used for the sake of `compute build` operations. + if language == nil { + var match bool + for _, l := range languages { + if strings.EqualFold(name, l.Name) { + language = l + match = true + break + } + } + if !match { + return fmt.Errorf("unrecognised package language") + } + } + return nil + }) + if err != nil { + return nil, err + } + + return language, nil +} + +// promptForPostInitContinue ensures the user is happy to continue with running +// the define post_init script in the fastly.toml manifest file. +func promptForPostInitContinue(msg, script string, out io.Writer, in io.Reader) error { + text.Info(out, "\n%s:\n", msg) + text.Indent(out, 4, "%s", script) + + label := "\nDo you want to run this now? [y/N] " + answer, err := text.AskYesNo(out, label, in) + if err != nil { + return err + } + if !answer { + return fsterr.ErrPostInitStopped + } + text.Break(out) + return nil +} + +// displayInitOutput of package information and useful links. +func displayInitOutput(name, dst, language string, out io.Writer) { + text.Break(out) + text.Description(out, fmt.Sprintf("Initialized package %s to", text.Bold(name)), dst) + + if language == "other" { + text.Description(out, "To package a pre-compiled Wasm binary for deployment, run", "fastly compute pack") + text.Description(out, "To deploy the package, run", "fastly compute deploy") + } else { + text.Description(out, "To publish the package (build and deploy), run", "fastly compute publish") + } + + text.Description(out, "To learn about deploying Compute projects using third-party orchestration tools, visit", "https://www.fastly.com/documentation/guides/integrations/orchestration") + text.Success(out, "Initialized package %s", text.Bold(name)) +} diff --git a/pkg/commands/compute/init_test.go b/pkg/commands/compute/init_test.go new file mode 100644 index 000000000..5299e7bdc --- /dev/null +++ b/pkg/commands/compute/init_test.go @@ -0,0 +1,809 @@ +package compute_test + +import ( + "bytes" + "errors" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/app" + "github.com/fastly/cli/pkg/config" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" + "github.com/fastly/cli/pkg/threadsafe" +) + +func TestInit(t *testing.T) { + args := testutil.SplitArgs + if os.Getenv("TEST_COMPUTE_INIT") == "" { + t.Log("skipping test") + t.Skip("Set TEST_COMPUTE_INIT to run this test") + } + + skRust := []config.StarterKit{ + { + Name: "Default", + Path: "https://github.com/fastly/compute-starter-kit-rust-default", + Branch: "main", + }, + } + skJS := []config.StarterKit{ + { + Name: "Default", + Path: "https://github.com/fastly/compute-starter-kit-javascript-default", + Branch: "main", + }, + } + + scenarios := []struct { + name string + args []string + configFile config.File + httpClientRes []*http.Response + httpClientErr []error + manifest string + wantFiles []string + unwantedFiles []string + wantError string + wantOutput []string + manifestIncludes string + manifestPath string + stdin string + setupSteps func() error + }{ + { + name: "broken endpoint", + args: args("compute init --from https://example.com/i-dont-exist"), + wantError: "failed to get package 'https://example.com/i-dont-exist': Not Found", + httpClientRes: []*http.Response{ + { + Body: io.NopCloser(strings.NewReader("")), + Status: http.StatusText(http.StatusNotFound), + StatusCode: http.StatusNotFound, + }, + }, + httpClientErr: []error{ + nil, + }, + }, + { + name: "name prompt", + args: args("compute init"), + configFile: config.File{ + StarterKits: config.StarterKitLanguages{ + Rust: skRust, + }, + }, + stdin: "foobar", // expect the first prompt to be for the package name. + wantOutput: []string{ + "Fetching package template", + "Reading fastly.toml", + }, + manifestIncludes: `name = "foobar"`, + }, + { + name: "description prompt empty", + args: args("compute init"), + configFile: config.File{ + StarterKits: config.StarterKitLanguages{ + Rust: skRust, + }, + }, + wantOutput: []string{ + "Fetching package template", + "Reading fastly.toml", + }, + manifestIncludes: `description = ""`, // expect this to be empty + }, + { + name: "with author", + args: args("compute init --author test@example.com"), + configFile: config.File{ + StarterKits: config.StarterKitLanguages{ + Rust: skRust, + }, + }, + wantOutput: []string{ + "Fetching package template", + "Reading fastly.toml", + }, + manifestIncludes: `authors = ["test@example.com"]`, + }, + { + name: "with multiple authors", + args: args("compute init --author test1@example.com --author test2@example.com"), + configFile: config.File{ + StarterKits: config.StarterKitLanguages{ + Rust: skRust, + }, + }, + wantOutput: []string{ + "Fetching package template", + "Reading fastly.toml", + }, + manifestIncludes: `authors = ["test1@example.com", "test2@example.com"]`, + }, + { + name: "with --from set to starter kit repository", + args: args("compute init --from https://github.com/fastly/compute-starter-kit-rust-default"), + configFile: config.File{ + StarterKits: config.StarterKitLanguages{ + Rust: []config.StarterKit{ + { + Name: "Default", + Path: "https://github.com/fastly/compute-starter-kit-rust-default.git", + }, + }, + }, + }, + wantOutput: []string{ + "Fetching package template", + "Reading fastly.toml", + "SUCCESS: Initialized package", + }, + }, + { + name: "with --from set to starter kit repository when dir with same name exists in pwd", + args: args("compute init --auto-yes --from https://github.com/fastly/compute-starter-kit-rust-default"), + configFile: config.File{ + StarterKits: config.StarterKitLanguages{ + Rust: []config.StarterKit{ + { + Name: "Default", + Path: "https://github.com/fastly/compute-starter-kit-rust-default.git", + }, + }, + }, + }, + wantOutput: []string{ + "Fetching package template", + "Reading fastly.toml", + "SUCCESS: Initialized package", + }, + setupSteps: func() error { + return os.MkdirAll("compute-starter-kit-rust-default", 0o755) + }, + }, + { + name: "with --from set to starter kit repository with .git extension and branch", + args: args("compute init --from https://github.com/fastly/compute-starter-kit-rust-default.git --branch main"), + configFile: config.File{ + StarterKits: config.StarterKitLanguages{ + Rust: []config.StarterKit{ + { + Name: "Default", + Path: "https://github.com/fastly/compute-starter-kit-rust-default.git", + }, + }, + }, + }, + wantOutput: []string{ + "Fetching package template", + "Reading fastly.toml", + "SUCCESS: Initialized package", + }, + }, + { + name: "with --from set to starter kit repository with .git extension and branch when dir with same name exists in pwd", + args: args("compute init --auto-yes --from https://github.com/fastly/compute-starter-kit-rust-default.git --branch main"), + configFile: config.File{ + StarterKits: config.StarterKitLanguages{ + Rust: []config.StarterKit{ + { + Name: "Default", + Path: "https://github.com/fastly/compute-starter-kit-rust-default.git", + }, + }, + }, + }, + wantOutput: []string{ + "Fetching package template", + "Reading fastly.toml", + "SUCCESS: Initialized package", + }, + setupSteps: func() error { + return os.MkdirAll("compute-starter-kit-rust-default.git", 0o755) + }, + }, + { + name: "with --from set to zip archive", + args: args("compute init --from https://github.com/fastly/compute-starter-kit-rust-default/archive/refs/heads/main.zip"), + configFile: config.File{ + StarterKits: config.StarterKitLanguages{ + Rust: []config.StarterKit{ + { + Name: "Default", + Path: "https://github.com/fastly/compute-starter-kit-rust-default.git", + }, + }, + }, + }, + wantOutput: []string{ + "Fetching package template", + "Reading fastly.toml", + "SUCCESS: Initialized package", + }, + }, + { + name: "with --from set to zip archive when file with same name exists in pwd", + args: args("compute init --auto-yes --from https://github.com/fastly/compute-starter-kit-rust-default/archive/refs/heads/main.zip"), + configFile: config.File{ + StarterKits: config.StarterKitLanguages{ + Rust: []config.StarterKit{ + { + Name: "Default", + Path: "https://github.com/fastly/compute-starter-kit-rust-default.git", + }, + }, + }, + }, + wantOutput: []string{ + "Fetching package template", + "Reading fastly.toml", + "SUCCESS: Initialized package", + }, + setupSteps: func() error { + file, err := os.Create("main.zip") + if file != nil { + defer file.Close() + } + return err + }, + }, + { + name: "with --from set to tar.gz archive", + args: args("compute init --from https://github.com/Integralist/devnull/files/7339887/compute-starter-kit-rust-default-main.tar.gz"), + configFile: config.File{ + StarterKits: config.StarterKitLanguages{ + Rust: []config.StarterKit{ + { + Name: "Default", + Path: "https://github.com/fastly/compute-starter-kit-rust-default.git", + }, + }, + }, + }, + wantOutput: []string{ + "Fetching package template", + "Reading fastly.toml", + "SUCCESS: Initialized package", + }, + }, + { + name: "with existing fastly.toml", + args: args("compute init --auto-yes"), // --force will ignore a directory that isn't empty + configFile: config.File{ + StarterKits: config.StarterKitLanguages{ + Rust: skRust, + }, + }, + manifest: ` + manifest_version = 2 + service_id = 1234 + name = "test" + language = "rust" + description = "test" + authors = ["test@fastly.com"]`, + wantOutput: []string{ + "Reading fastly.toml", + "Saving manifest changes", + "Initializing package", + }, + }, + { + name: "no args and no user profiles means no email set for author field", + args: args("compute init"), + configFile: config.File{ + StarterKits: config.StarterKitLanguages{ + Rust: skRust, + }, + }, + wantFiles: []string{ + "Cargo.toml", + "fastly.toml", + "src/main.rs", + }, + unwantedFiles: []string{ + "SECURITY.md", + }, + wantOutput: []string{ + "Author (email):", + "Language:", + "Fetching package template", + "Reading fastly.toml", + "Saving manifest changes", + "Initializing package", + }, + }, + { + name: "no args but email defaults to config.toml value in author field", + args: args("compute init"), + configFile: config.File{ + Profiles: config.Profiles{ + "user": &config.Profile{ + Email: "test@example.com", + Default: true, + }, + "non_default": &config.Profile{ + Email: "no-default@example.com", + }, + }, + StarterKits: config.StarterKitLanguages{ + Rust: skRust, + }, + }, + manifestIncludes: `authors = ["test@example.com"]`, + wantFiles: []string{ + "Cargo.toml", + "fastly.toml", + "src/main.rs", + }, + unwantedFiles: []string{ + "SECURITY.md", + }, + wantOutput: []string{ + "Fetching package template", + "Reading fastly.toml", + "Saving manifest changes", + "Initializing package", + }, + }, + { + name: "non empty directory", + args: args("compute init"), + configFile: config.File{ + StarterKits: config.StarterKitLanguages{ + Rust: skRust, + }, + }, + wantError: "project directory not empty", + manifest: ` + manifest_version = 2 + name = "test"`, + }, + { + name: "with default name inferred from directory", + args: args("compute init"), + configFile: config.File{ + StarterKits: config.StarterKitLanguages{ + Rust: skRust, + }, + }, + manifestIncludes: `name = "fastly-temp`, + }, + { + name: "with directory name inferred from --directory", + args: args("compute init --directory ./foo"), + configFile: config.File{ + StarterKits: config.StarterKitLanguages{ + Rust: skRust, + }, + }, + stdin: "Y", + manifest: `manifest_version = 2`, + manifestPath: "foo", + manifestIncludes: `name = "foo`, + }, + { + name: "with JavaScript language", + args: args("compute init --language javascript"), + configFile: config.File{ + StarterKits: config.StarterKitLanguages{ + JavaScript: skJS, + }, + }, + manifestIncludes: `name = "fastly-temp`, + }, + // NOTE: This test verifies that we don't fetch a remote project. + // Whether that be a starter kit or custom project template. + // This is because "other" indicates an unsupported platform language. + { + name: "with pre-compiled Wasm binary", + args: args("compute init --language other"), + manifestIncludes: `language = "other"`, + wantOutput: []string{ + "Initialized package", + "To package a pre-compiled Wasm binary for deployment", + "SUCCESS: Initialized package", + }, + }, + } + for _, testcase := range scenarios { + t.Run(testcase.name, func(t *testing.T) { + // We're going to chdir to an init environment, + // so save the PWD to return to, afterwards. + pwd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + + manifestPath := filepath.Join(testcase.manifestPath, manifest.Filename) + + // Create test environment + rootdir := testutil.NewEnv(testutil.EnvOpts{ + T: t, + Write: []testutil.FileIO{ + {Src: testcase.manifest, Dst: manifestPath}, + }, + }) + defer os.RemoveAll(rootdir) + + // Before running the test, chdir into the init environment. + // When we're done, chdir back to our original location. + // This is so we can reliably assert file structure. + if err := os.Chdir(rootdir); err != nil { + t.Fatal(err) + } + defer func() { + _ = os.Chdir(pwd) + }() + + // Before running the test, run some steps to initialize the environment. + if testcase.setupSteps != nil { + if err := testcase.setupSteps(); err != nil { + t.Fatal(err) + } + } + + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.Config = testcase.configFile + + if testcase.httpClientRes != nil || testcase.httpClientErr != nil { + opts.HTTPClient = mock.HTMLClient(testcase.httpClientRes, testcase.httpClientErr) + } + + // we need to define stdin as the init process prompts the user multiple + // times, but we don't need to provide any values as all our prompts will + // fallback to default values if the input is unrecognised. + opts.Input = strings.NewReader(testcase.stdin) + return opts, nil + } + err = app.Run(testcase.args, nil) + + t.Log(stdout.String()) + + testutil.AssertErrorContains(t, err, testcase.wantError) + for _, file := range testcase.wantFiles { + if _, err := os.Stat(filepath.Join(rootdir, file)); err != nil { + t.Errorf("wanted file %s not found", file) + } + } + for _, file := range testcase.unwantedFiles { + if _, err := os.Stat(filepath.Join(rootdir, file)); !errors.Is(err, os.ErrNotExist) { + t.Errorf("unwanted file %s found", file) + } + } + for _, s := range testcase.wantOutput { + testutil.AssertStringContains(t, stdout.String(), s) + } + if testcase.manifestIncludes != "" { + content, err := os.ReadFile(filepath.Join(rootdir, manifestPath)) + if err != nil { + t.Fatal(err) + } + testutil.AssertStringContains(t, string(content), testcase.manifestIncludes) + } + }) + } +} + +func TestInit_ExistingService(t *testing.T) { + serviceID := fastly.NullString("LsyQ2UXDGk6d4ENjvgqTN4") + customerID := fastly.NullString("YflD2HKQTx6q4RAwitdGA4") + packageID := fastly.NullString("4AGdtiwAR4q6xTQKH2DlfY") + + scenarios := []struct { + name string + args []string + getServiceDetails func(*fastly.GetServiceInput) (*fastly.ServiceDetail, error) + getPackage func(*fastly.GetPackageInput) (*fastly.Package, error) + expectInOutput []string + expectInManifest []string + expectNoManifest bool + expectInError string + suppresBeacon bool + }{ + { + name: "when the service exists", + args: testutil.SplitArgs("compute init --from LsyQ2UXDGk6d4ENjvgqTN4"), + getServiceDetails: func(gsi *fastly.GetServiceInput) (*fastly.ServiceDetail, error) { + if gsi.ServiceID != *serviceID { + return nil, &fastly.HTTPError{ + StatusCode: http.StatusNotFound, + } + } + return &fastly.ServiceDetail{ + ServiceID: serviceID, + CustomerID: customerID, + Comment: fastly.ToPointer(""), + Name: fastly.ToPointer("example service"), + Type: fastly.ToPointer("wasm"), + ActiveVersion: &fastly.Version{ + Number: fastly.ToPointer(1), + }, + }, nil + }, + getPackage: func(gpi *fastly.GetPackageInput) (*fastly.Package, error) { + if gpi.ServiceID != *serviceID || gpi.ServiceVersion != 1 { + return nil, &fastly.HTTPError{ + StatusCode: http.StatusNotFound, + } + } + return &fastly.Package{ + PackageID: packageID, + ServiceID: serviceID, + Metadata: &fastly.PackageMetadata{ + Authors: []string{"author@example.com"}, + Description: fastly.NullString("a description"), + Name: fastly.NullString("test-package"), + Language: fastly.NullString("rust"), + }, + }, nil + }, + expectInOutput: []string{ + "Initializing Compute project from service LsyQ2UXDGk6d4ENjvgqTN4.", + "SUCCESS: Initialized package test-package", + }, + expectInManifest: []string{ + `name = "test-package"`, + `authors = ["author@example.com"]`, + `description = "a description"`, + `language = "rust"`, + `service_id = "LsyQ2UXDGk6d4ENjvgqTN4"`, + }, + }, + { + name: "when the service doesn't exist", + args: testutil.SplitArgs("compute init --from LsyQ2UXDGk6d4ENjvgqTN4"), + getServiceDetails: func(_ *fastly.GetServiceInput) (*fastly.ServiceDetail, error) { + return nil, &fastly.HTTPError{ + StatusCode: http.StatusNotFound, + } + }, + expectInOutput: []string{ + "Initializing Compute project from service LsyQ2UXDGk6d4ENjvgqTN4.", + }, + expectInError: "the service LsyQ2UXDGk6d4ENjvgqTN4 could not be found", + expectNoManifest: true, + }, + { + name: "service has no versions that include package metadata", + args: testutil.SplitArgs("compute init --from LsyQ2UXDGk6d4ENjvgqTN4"), + getServiceDetails: func(_ *fastly.GetServiceInput) (*fastly.ServiceDetail, error) { + return &fastly.ServiceDetail{ + ServiceID: serviceID, + Name: fastly.NullString("test-service"), + Comment: fastly.NullString(""), + Type: fastly.NullString("wasm"), + ActiveVersion: nil, + Versions: []*fastly.Version{ + { + Active: fastly.ToPointer(false), + Deployed: fastly.ToPointer(false), + Locked: fastly.ToPointer(false), + Number: fastly.ToPointer(1), + }, + }, + }, nil + }, + getPackage: func(_ *fastly.GetPackageInput) (*fastly.Package, error) { + return nil, &fastly.HTTPError{ + StatusCode: http.StatusNotFound, + } + }, + expectInError: "unable to find any version of service LsyQ2UXDGk6d4ENjvgqTN4 with an existing package", + }, + { + name: "service is vcl", + args: testutil.SplitArgs("compute init --from LsyQ2UXDGk6d4ENjvgqTN4"), + getServiceDetails: func(*fastly.GetServiceInput) (*fastly.ServiceDetail, error) { + return &fastly.ServiceDetail{ + ServiceID: serviceID, + Type: fastly.NullString("vcl"), + }, nil + }, + expectInError: "service LsyQ2UXDGk6d4ENjvgqTN4 is not a Compute service", + expectNoManifest: true, + }, + { + name: "service id does not look like a Fastly ID", + args: testutil.SplitArgs("compute init --from LsyQ2UXDGk6d4EN"), + expectInError: "--from url seems invalid", + // Not a valid URL OR Service ID + suppresBeacon: true, + }, + { + name: "service has a cloned_from value", + args: testutil.SplitArgs("compute init --from LsyQ2UXDGk6d4ENjvgqTN4"), + getServiceDetails: func(*fastly.GetServiceInput) (*fastly.ServiceDetail, error) { + return &fastly.ServiceDetail{ + ServiceID: serviceID, + Name: fastly.NullString("cloned-service"), + Comment: fastly.NullString(""), + Type: fastly.NullString("wasm"), + ActiveVersion: &fastly.Version{ + Number: fastly.ToPointer(1), + }, + }, nil + }, + getPackage: func(*fastly.GetPackageInput) (*fastly.Package, error) { + return &fastly.Package{ + ServiceID: serviceID, + PackageID: fastly.NullString("hVPTrHgswnF5KFwFKoQz1f"), + Metadata: &fastly.PackageMetadata{ + ClonedFrom: fastly.ToPointer("https://github.com/fastly/compute-starter-kit-rust-empty"), + Language: fastly.ToPointer("rust"), + }, + }, nil + }, + expectInOutput: []string{"Initializing file structure from selected starter kit..."}, + }, + { + name: "service has an unreachable cloned_from value", + args: testutil.SplitArgs("compute init --from LsyQ2UXDGk6d4ENjvgqTN4"), + getServiceDetails: func(*fastly.GetServiceInput) (*fastly.ServiceDetail, error) { + return &fastly.ServiceDetail{ + ServiceID: serviceID, + Name: fastly.NullString("cloned-service"), + Comment: fastly.NullString(""), + Type: fastly.NullString("wasm"), + ActiveVersion: &fastly.Version{ + Number: fastly.ToPointer(1), + }, + }, nil + }, + getPackage: func(*fastly.GetPackageInput) (*fastly.Package, error) { + return &fastly.Package{ + ServiceID: serviceID, + PackageID: fastly.NullString("hVPTrHgswnF5KFwFKoQz1f"), + Metadata: &fastly.PackageMetadata{ + ClonedFrom: fastly.ToPointer("https://github.com/fastly/fake-template"), + Language: fastly.ToPointer("rust"), + }, + }, nil + }, + expectInError: "could not fetch original source code", + }, + { + name: "service has active version greater than 1", + args: testutil.SplitArgs("compute init --from LsyQ2UXDGk6d4ENjvgqTN4"), + getServiceDetails: func(*fastly.GetServiceInput) (*fastly.ServiceDetail, error) { + return &fastly.ServiceDetail{ + ServiceID: serviceID, + Name: fastly.NullString("cloned-service"), + Comment: fastly.NullString(""), + Type: fastly.NullString("wasm"), + ActiveVersion: &fastly.Version{ + Number: fastly.ToPointer(2), + }, + }, nil + }, + getPackage: func(*fastly.GetPackageInput) (*fastly.Package, error) { + return &fastly.Package{ + ServiceID: serviceID, + PackageID: fastly.NullString("hVPTrHgswnF5KFwFKoQz1f"), + Metadata: &fastly.PackageMetadata{ + ClonedFrom: fastly.ToPointer("https://github.com/fastly/fake-template"), + Language: fastly.ToPointer("rust"), + }, + }, nil + }, + expectInOutput: []string{"not fetching starter kit source"}, + }, + } + + for _, testcase := range scenarios { + t.Run(testcase.name, func(t *testing.T) { + // We're going to chdir to an init environment, + // so save the PWD to return to, afterwards. + pwd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + defer func() { + _ = os.Chdir(pwd) + }() + + // Create test environment + rootdir := testutil.NewEnv(testutil.EnvOpts{ + T: t, + Write: []testutil.FileIO{ + {Src: "", Dst: manifest.Filename}, + }, + }) + defer os.RemoveAll(rootdir) + + manifestPath := filepath.Join(rootdir, manifest.Filename) + + // Before running the test, chdir into the init environment. + // When we're done, chdir back to our original location. + // This is so we can reliably assert file structure. + if err := os.Chdir(rootdir); err != nil { + t.Fatal(err) + } + + httpClient := &mock.HTTPClient{ + Responses: []*http.Response{ + // The body is closed by beacon.Notify. + //nolint: bodyclose + mock.NewHTTPResponse(http.StatusNoContent, nil, nil), + }, + Errors: []error{ + nil, + }, + Index: -1, + SaveRequests: true, + } + + stdout := &threadsafe.Buffer{} + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, stdout) + opts.APIClientFactory = mock.APIClient(mock.API{ + GetServiceDetailsFn: testcase.getServiceDetails, + GetPackageFn: testcase.getPackage, + }) + opts.Input = strings.NewReader("") + opts.HTTPClient = httpClient + + return opts, nil + } + + err = app.Run(testcase.args, nil) + + if testcase.expectInError == "" { + if err != nil { + t.Fatal(err) + } + } else { + if err == nil { + t.Log("expected an error and did not get one") + t.Fail() + } + testutil.AssertErrorContains(t, err, testcase.expectInError) + } + + t.Log(stdout.String()) + + if testcase.suppresBeacon { + testutil.AssertLength(t, 0, httpClient.Requests) + } else { + testutil.AssertLength(t, 1, httpClient.Requests) + beaconReq := httpClient.Requests[0] + testutil.AssertEqual(t, "fastly-notification-relay.edgecompute.app", beaconReq.URL.Hostname()) + } + + for _, s := range testcase.expectInOutput { + testutil.AssertStringContains(t, stdout.String(), s) + } + + if testcase.expectNoManifest { + _, err = os.Stat(manifestPath) + if err == nil { + t.Log("found unexpected manifest file", manifestPath) + t.Fail() + } + } + + if len(testcase.expectInManifest) > 0 { + mfContentBytes, err := os.ReadFile(manifestPath) + if err != nil { + t.Fatal(err) + } + mfContent := string(mfContentBytes) + for _, s := range testcase.expectInManifest { + testutil.AssertStringContains(t, mfContent, s) + } + } + }) + } +} diff --git a/pkg/commands/compute/language.go b/pkg/commands/compute/language.go new file mode 100644 index 000000000..9b65d2738 --- /dev/null +++ b/pkg/commands/compute/language.go @@ -0,0 +1,119 @@ +package compute + +import ( + "fmt" + "runtime" + "sort" + "strings" + + "github.com/fastly/cli/pkg/config" +) + +// NewLanguages returns a list of supported programming languages. +// +// NOTE: The 'timeout' value zero is passed into each New call as it's +// only useful during the `compute build` phase and is expected to be +// provided by the user via a flag on the build command. +func NewLanguages(kits config.StarterKitLanguages) []*Language { + // WARNING: Do not reorder these options as they affect the rendered output. + // They are placed in order of language maturity/importance. + // + // A change to this order will also break the tests, as the logic defaults to + // the first language in the list if nothing entered at the relevant language + // prompt. + return []*Language{ + NewLanguage(&LanguageOptions{ + Name: "rust", + DisplayName: "Rust", + StarterKits: kits.Rust, + }), + NewLanguage(&LanguageOptions{ + Name: "javascript", + DisplayName: "JavaScript", + StarterKits: kits.JavaScript, + }), + NewLanguage(&LanguageOptions{ + Name: "go", + DisplayName: "Go", + StarterKits: kits.Go, + }), + NewLanguage(&LanguageOptions{ + Name: "other", + DisplayName: "Other ('bring your own' Wasm binary)", + }), + } +} + +// NewLanguage constructs a new Language from a LangaugeOptions. +func NewLanguage(options *LanguageOptions) *Language { + // Ensure the 'default' starter kit is always first. + sort.Slice(options.StarterKits, func(i, j int) bool { + suffix := fmt.Sprintf("%s-default", options.Name) + a := strings.HasSuffix(options.StarterKits[i].Path, suffix) + b := strings.HasSuffix(options.StarterKits[j].Path, suffix) + var ( + bitSetA int8 + bitSetB int8 + ) + if a { + bitSetA = 1 + } + if b { + bitSetB = 1 + } + return bitSetA > bitSetB + }) + + return &Language{ + options.Name, + options.DisplayName, + options.StarterKits, + options.SourceDirectory, + options.Toolchain, + } +} + +// Language models a Compute source language. +type Language struct { + Name string + DisplayName string + StarterKits []config.StarterKit + SourceDirectory string + + Toolchain +} + +// LanguageOptions models configuration options for a Language. +type LanguageOptions struct { + Name string + DisplayName string + StarterKits []config.StarterKit + SourceDirectory string + Toolchain Toolchain +} + +// Shell represents a subprocess shell used by `compute` environment where +// `[scripts.build]` has been defined within fastly.toml manifest. +type Shell struct{} + +// Build expects a command that can be prefixed with an appropriate subprocess +// shell. +// +// Example: +// build = "yarn install && yarn build" +// +// Should be converted into a command such as (on unix): +// sh -c "yarn install && yarn build". +func (s Shell) Build(command string) (cmd string, args []string) { + cmd = "sh" + args = []string{"-c"} + + if runtime.GOOS == "windows" { + cmd = "cmd.exe" + args = []string{"/C"} + } + + args = append(args, command) + + return cmd, args +} diff --git a/pkg/commands/compute/language_assemblyscript.go b/pkg/commands/compute/language_assemblyscript.go new file mode 100644 index 000000000..dedcfc545 --- /dev/null +++ b/pkg/commands/compute/language_assemblyscript.go @@ -0,0 +1,215 @@ +package compute + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "os" + + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/text" +) + +// AsDefaultBuildCommand is a build command compiled into the CLI binary so it +// can be used as a fallback for customer's who have an existing Compute project and +// are simply upgrading their CLI version and might not be familiar with the +// changes in the 4.0.0 release with regards to how build logic has moved to the +// fastly.toml manifest. +// +// NOTE: In the 5.x CLI releases we persisted the default to the fastly.toml +// We no longer do that. In 6.x we use the default and just inform the user. +// This makes the experience less confusing as users didn't expect file changes. +var AsDefaultBuildCommand = fmt.Sprintf("npm exec -- asc assembly/index.ts --outFile %s --optimize --noAssert", binWasmPath) + +// AsDefaultBuildCommandForWebpack is a build command compiled into the CLI +// binary so it can be used as a fallback for customer's who have an existing +// Compute project using the 'default' JS Starter Kit, and are simply upgrading +// their CLI version and might not be familiar with the changes in the 4.0.0 +// release with regards to how build logic has moved to the fastly.toml manifest. +// +// NOTE: For this variation of the build script to be added to the user's +// fastly.toml will require a successful check for the webpack dependency. +var AsDefaultBuildCommandForWebpack = fmt.Sprintf("npm exec webpack && npm exec -- asc assembly/index.ts --outFile %s --optimize --noAssert", binWasmPath) + +// AsSourceDirectory represents the source code directory. +const AsSourceDirectory = "assembly" + +// NewAssemblyScript constructs a new AssemblyScript toolchain. +func NewAssemblyScript( + c *BuildCommand, + in io.Reader, + manifestFilename string, + out io.Writer, + spinner text.Spinner, +) *AssemblyScript { + return &AssemblyScript{ + Shell: Shell{}, + + build: c.Globals.Manifest.File.Scripts.Build, + errlog: c.Globals.ErrLog, + input: in, + manifestFilename: manifestFilename, + metadataFilterEnvVars: c.MetadataFilterEnvVars, + output: out, + postBuild: c.Globals.Manifest.File.Scripts.PostBuild, + spinner: spinner, + timeout: c.Flags.Timeout, + verbose: c.Globals.Verbose(), + } +} + +// AssemblyScript implements a Toolchain for the AssemblyScript language. +type AssemblyScript struct { + Shell + + // autoYes is the --auto-yes flag. + autoYes bool + // build is a shell command defined in fastly.toml using [scripts.build]. + build string + // defaultBuild indicates if the default build script was used. + defaultBuild bool + // errlog is an abstraction for recording errors to disk. + errlog fsterr.LogInterface + // input is the user's terminal stdin stream + input io.Reader + // manifestFilename is the name of the manifest file. + manifestFilename string + // metadataFilterEnvVars is a comma-separated list of user defined env vars. + metadataFilterEnvVars string + // nonInteractive is the --non-interactive flag. + nonInteractive bool + // output is the users terminal stdout stream + output io.Writer + // postBuild is a custom script executed after the build but before the Wasm + // binary is added to the .tar.gz archive. + postBuild string + // spinner is a terminal progress status indicator. + spinner text.Spinner + // timeout is the build execution threshold. + timeout int + // verbose indicates if the user set --verbose + verbose bool +} + +// DefaultBuildScript indicates if a custom build script was used. +func (a *AssemblyScript) DefaultBuildScript() bool { + return a.defaultBuild +} + +// Dependencies returns all dependencies used by the project. +func (a *AssemblyScript) Dependencies() map[string]string { + deps := make(map[string]string) + + lockfile := "npm-shrinkwrap.json" + _, err := os.Stat(lockfile) + if errors.Is(err, os.ErrNotExist) { + lockfile = "package-lock.json" + } + + var jlf JavaScriptLockFile + if f, err := os.Open(lockfile); err == nil { + if err := json.NewDecoder(f).Decode(&jlf); err == nil { + for k, v := range jlf.Packages { + if k != "" { // avoid "root" package + deps[k] = v.Version + } + } + } + } + + return deps +} + +// Build compiles the user's source code into a Wasm binary. +func (a *AssemblyScript) Build() error { + if !a.verbose { + text.Break(a.output) + } + text.Deprecated(a.output, "The Fastly AssemblyScript SDK is being deprecated in favor of the more up-to-date and feature-rich JavaScript SDK. You can learn more about the JavaScript SDK on our Developer Hub Page - https://www.fastly.com/documentation/guides/computejavascript/\n\n") + + if a.build == "" { + a.build = AsDefaultBuildCommand + a.defaultBuild = true + } + + usesWebpack, err := a.checkForWebpack() + if err != nil { + return err + } + if usesWebpack { + a.build = AsDefaultBuildCommandForWebpack + } + + if a.defaultBuild && a.verbose { + text.Info(a.output, "No [scripts.build] found in %s. The following default build command for AssemblyScript will be used: `%s`\n\n", a.manifestFilename, a.build) + } + + bt := BuildToolchain{ + autoYes: a.autoYes, + buildFn: a.Shell.Build, + buildScript: a.build, + errlog: a.errlog, + in: a.input, + manifestFilename: a.manifestFilename, + metadataFilterEnvVars: a.metadataFilterEnvVars, + nonInteractive: a.nonInteractive, + out: a.output, + postBuild: a.postBuild, + spinner: a.spinner, + timeout: a.timeout, + verbose: a.verbose, + } + + return bt.Build() +} + +func (a AssemblyScript) checkForWebpack() (bool, error) { + wd, err := os.Getwd() + if err != nil { + return false, err + } + + home, err := os.UserHomeDir() + if err != nil { + return false, err + } + + found, path, err := search("package.json", wd, home) + if err != nil { + return false, err + } + + if found { + // gosec flagged this: + // G304 (CWE-22): Potential file inclusion via variable + // + // Disabling as the path is determined by our own logic. + /* #nosec */ + data, err := os.ReadFile(path) + if err != nil { + return false, err + } + + var pkg NPMPackage + + err = json.Unmarshal(data, &pkg) + if err != nil { + return false, err + } + + for k := range pkg.DevDependencies { + if k == "webpack" { + return true, nil + } + } + + for k := range pkg.Dependencies { + if k == "webpack" { + return true, nil + } + } + } + + return false, nil +} diff --git a/pkg/commands/compute/language_go.go b/pkg/commands/compute/language_go.go new file mode 100644 index 000000000..161863d84 --- /dev/null +++ b/pkg/commands/compute/language_go.go @@ -0,0 +1,323 @@ +package compute + +import ( + "bufio" + "errors" + "fmt" + "io" + "os" + "os/exec" + "regexp" + "strings" + + "github.com/Masterminds/semver/v3" + "golang.org/x/mod/modfile" + + "github.com/fastly/cli/pkg/config" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/text" +) + +// TinyGoDefaultBuildCommand is a build command compiled into the CLI binary so it +// can be used as a fallback for customer's who have an existing Compute project and +// are simply upgrading their CLI version and might not be familiar with the +// changes in the 4.0.0 release with regards to how build logic has moved to the +// fastly.toml manifest. +// +// NOTE: In the 5.x CLI releases we persisted the default to the fastly.toml +// We no longer do that. In 6.x we use the default and just inform the user. +// This makes the experience less confusing as users didn't expect file changes. +var TinyGoDefaultBuildCommand = fmt.Sprintf("tinygo build -target=wasi -gc=conservative -o %s ./", binWasmPath) + +// GoSourceDirectory represents the source code directory. +const GoSourceDirectory = "." + +// NewGo constructs a new Go toolchain. +func NewGo( + c *BuildCommand, + in io.Reader, + manifestFilename string, + out io.Writer, + spinner text.Spinner, +) *Go { + return &Go{ + Shell: Shell{}, + + autoYes: c.Globals.Flags.AutoYes, + build: c.Globals.Manifest.File.Scripts.Build, + config: c.Globals.Config.Language.Go, + env: c.Globals.Manifest.File.Scripts.EnvVars, + errlog: c.Globals.ErrLog, + input: in, + manifestFilename: manifestFilename, + metadataFilterEnvVars: c.MetadataFilterEnvVars, + nonInteractive: c.Globals.Flags.NonInteractive, + output: out, + postBuild: c.Globals.Manifest.File.Scripts.PostBuild, + spinner: spinner, + timeout: c.Flags.Timeout, + verbose: c.Globals.Verbose(), + } +} + +// Go implements a Toolchain for the TinyGo language. +// +// NOTE: Two separate tools are required to support golang development. +// +// 1. Go: for defining required packages in a go.mod project module. +// 2. TinyGo: used to compile the go project. +type Go struct { + Shell + + // autoYes is the --auto-yes flag. + autoYes bool + // build is a shell command defined in fastly.toml using [scripts.build]. + build string + // config is the Go specific application configuration. + config config.Go + // defaultBuild indicates if the default build script was used. + defaultBuild bool + // env is environment variables to be set. + env []string + // errlog is an abstraction for recording errors to disk. + errlog fsterr.LogInterface + // input is the user's terminal stdin stream + input io.Reader + // manifestFilename is the name of the manifest file. + manifestFilename string + // metadataFilterEnvVars is a comma-separated list of user defined env vars. + metadataFilterEnvVars string + // nonInteractive is the --non-interactive flag. + nonInteractive bool + // output is the users terminal stdout stream + output io.Writer + // postBuild is a custom script executed after the build but before the Wasm + // binary is added to the .tar.gz archive. + postBuild string + // spinner is a terminal progress status indicator. + spinner text.Spinner + // timeout is the build execution threshold. + timeout int + // verbose indicates if the user set --verbose + verbose bool +} + +// DefaultBuildScript indicates if a custom build script was used. +func (g *Go) DefaultBuildScript() bool { + return g.defaultBuild +} + +// Dependencies returns all dependencies used by the project. +func (g *Go) Dependencies() map[string]string { + deps := make(map[string]string) + data, err := os.ReadFile("go.mod") + if err != nil { + return deps + } + f, err := modfile.ParseLax("go.mod", data, nil) + if err != nil { + return deps + } + for _, req := range f.Require { + if req.Indirect { + continue + } + deps[req.Mod.Path] = req.Mod.Version + } + return deps +} + +// Build compiles the user's source code into a Wasm binary. +func (g *Go) Build() error { + var ( + tinygoToolchain bool + toolchainConstraint string + ) + + if g.build == "" { + g.build = TinyGoDefaultBuildCommand + g.defaultBuild = true + tinygoToolchain = true + toolchainConstraint = g.config.ToolchainConstraintTinyGo + if !g.verbose { + text.Break(g.output) + } + text.Info(g.output, "No [scripts.build] found in %s. Visit https://www.fastly.com/documentation/guides/compute/go/ to learn how to target standard Go vs TinyGo.\n\n", g.manifestFilename) + text.Description(g.output, "The following default build command for TinyGo will be used", g.build) + } + + if g.build != "" { + // IMPORTANT: All Fastly starter-kits for Go/TinyGo will have build script. + // + // So we'll need to parse the build script to identify if TinyGo is used so + // we can set the constraints appropriately. + if strings.Contains(g.build, "tinygo build") { + tinygoToolchain = true + toolchainConstraint = g.config.ToolchainConstraintTinyGo + } else { + toolchainConstraint = g.config.ToolchainConstraint + } + } + + // IMPORTANT: The Go SDK 0.2.0 bumps the tinygo requirement to 0.28.1 + // + // This means we need to check the go.mod of the user's project for + // `compute-sdk-go` and then parse the version and identify if it's less than + // 0.2.0 version. If it less than, change the TinyGo constraint to 0.26.0 + tinygoConstraint := identifyTinyGoConstraint(g.config.TinyGoConstraint, g.config.TinyGoConstraintFallback) + + g.toolchainConstraint( + "go", `go version go(?P\d[^\s]+)`, toolchainConstraint, + ) + + if tinygoToolchain { + g.toolchainConstraint( + "tinygo", `tinygo version (?P\d[^\s]+)`, tinygoConstraint, + ) + } + + bt := BuildToolchain{ + autoYes: g.autoYes, + buildFn: g.Shell.Build, + buildScript: g.build, + env: g.env, + errlog: g.errlog, + in: g.input, + manifestFilename: g.manifestFilename, + metadataFilterEnvVars: g.metadataFilterEnvVars, + nonInteractive: g.nonInteractive, + out: g.output, + postBuild: g.postBuild, + spinner: g.spinner, + timeout: g.timeout, + verbose: g.verbose, + } + + return bt.Build() +} + +// identifyTinyGoConstraint checks the compute-sdk-go version used by the +// project and if it's less than 0.2.0 we'll change the TinyGo constraint to be +// version 0.26.0 +// +// We do this because the 0.2.0 release of the compute-sdk-go bumps the TinyGo +// version requirement to 0.28.1 and we want to avoid any scenarios where a +// bump in SDK version causes the user's build to break (which would happen for +// users with a pre-existing project who happen to update their CLI version: the +// new CLI version would have a TinyGo constraint that would be higher than +// before and would stop their build from working). +// +// NOTE: The `configConstraint` is the latest CLI application config version. +// If there are any errors trying to parse the go.mod we'll default to the +// config constraint. +func identifyTinyGoConstraint(configConstraint, fallback string) string { + moduleName := "github.com/fastly/compute-sdk-go" + version := "" + + f, err := os.Open("go.mod") + if err != nil { + return configConstraint + } + defer f.Close() + + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + parts := strings.Fields(line) + + // go.mod has two separate definition possibilities: + // + // 1. + // require github.com/fastly/compute-sdk-go v0.1.7 + // + // 2. + // require ( + // github.com/fastly/compute-sdk-go v0.1.7 + // ) + if len(parts) >= 2 { + // 1. require [github.com/fastly/compute-sdk-go] v0.1.7 + if parts[1] == moduleName { + version = strings.TrimPrefix(parts[2], "v") + break + } + // 2. [github.com/fastly/compute-sdk-go] v0.1.7 + if parts[0] == moduleName { + version = strings.TrimPrefix(parts[1], "v") + break + } + } + } + + if err := scanner.Err(); err != nil { + return configConstraint + } + + if version == "" { + return configConstraint + } + + gomodVersion, err := semver.NewVersion(version) + if err != nil { + return configConstraint + } + + // 0.2.0 introduces the break by bumping the TinyGo minimum version to 0.28.1 + breakingSDKVersion, err := semver.NewVersion("0.2.0") + if err != nil { + return configConstraint + } + + if gomodVersion.LessThan(breakingSDKVersion) { + return fallback + } + + return configConstraint +} + +// toolchainConstraint warns the user if the required constraint is not met. +// +// NOTE: We don't stop the build as their toolchain may compile successfully. +// The warning is to help a user know something isn't quite right and gives them +// the opportunity to do something about it if they choose. +func (g *Go) toolchainConstraint(toolchain, pattern, constraint string) { + if g.verbose { + text.Info(g.output, "The Fastly CLI build step requires a %s version '%s'.\n\n", toolchain, constraint) + } + + versionCommand := fmt.Sprintf("%s version", toolchain) + args := strings.Split(versionCommand, " ") + + // gosec flagged this: + // G204 (CWE-78): Subprocess launched with function call as argument or cmd arguments + // Disabling as we trust the source of the variable. + // #nosec + // nosemgrep + cmd := exec.Command(args[0], args[1:]...) + stdoutStderr, err := cmd.CombinedOutput() + output := string(stdoutStderr) + if err != nil { + return + } + + versionPattern := regexp.MustCompile(pattern) + match := versionPattern.FindStringSubmatch(output) + if len(match) < 2 { // We expect a pattern with one capture group. + return + } + version := match[1] + + v, err := semver.NewVersion(version) + if err != nil { + return + } + + c, err := semver.NewConstraint(constraint) + if err != nil { + return + } + + valid, errs := c.Validate(v) + if !valid { + text.Warning(g.output, "The %s version requirement was not satisfied: %s", toolchain, errors.Join(errs...)) + } +} diff --git a/pkg/commands/compute/language_javascript.go b/pkg/commands/compute/language_javascript.go new file mode 100644 index 000000000..c33f972b1 --- /dev/null +++ b/pkg/commands/compute/language_javascript.go @@ -0,0 +1,256 @@ +package compute + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/text" +) + +// JsDefaultBuildCommand is a build command compiled into the CLI binary so it +// can be used as a fallback for customer's who have an existing Compute project and +// are simply upgrading their CLI version and might not be familiar with the +// changes in the 4.0.0 release with regards to how build logic has moved to the +// fastly.toml manifest. +// +// NOTE: In the 5.x CLI releases we persisted the default to the fastly.toml +// We no longer do that. In 6.x we use the default and just inform the user. +// This makes the experience less confusing as users didn't expect file changes. +var JsDefaultBuildCommand = fmt.Sprintf("npm exec js-compute-runtime ./src/index.js %s", binWasmPath) + +// JsDefaultBuildCommandForWebpack is a build command compiled into the CLI +// binary so it can be used as a fallback for customer's who have an existing +// Compute project using the 'default' JS Starter Kit, and are simply upgrading +// their CLI version and might not be familiar with the changes in the 4.0.0 +// release with regards to how build logic has moved to the fastly.toml manifest. +// +// NOTE: For this variation of the build script to be added to the user's +// fastly.toml will require a successful check for the webpack dependency. +var JsDefaultBuildCommandForWebpack = fmt.Sprintf("npm exec webpack && npm exec js-compute-runtime ./bin/index.js %s", binWasmPath) + +// JsSourceDirectory represents the source code directory. +const JsSourceDirectory = "src" + +// NewJavaScript constructs a new JavaScript toolchain. +func NewJavaScript( + c *BuildCommand, + in io.Reader, + manifestFilename string, + out io.Writer, + spinner text.Spinner, +) *JavaScript { + return &JavaScript{ + Shell: Shell{}, + + autoYes: c.Globals.Flags.AutoYes, + build: c.Globals.Manifest.File.Scripts.Build, + env: c.Globals.Manifest.File.Scripts.EnvVars, + errlog: c.Globals.ErrLog, + input: in, + manifestFilename: manifestFilename, + metadataFilterEnvVars: c.MetadataFilterEnvVars, + nonInteractive: c.Globals.Flags.NonInteractive, + output: out, + postBuild: c.Globals.Manifest.File.Scripts.PostBuild, + spinner: spinner, + timeout: c.Flags.Timeout, + verbose: c.Globals.Verbose(), + } +} + +// JavaScript implements a Toolchain for the JavaScript language. +type JavaScript struct { + Shell + + // autoYes is the --auto-yes flag. + autoYes bool + // build is a shell command defined in fastly.toml using [scripts.build]. + build string + // defaultBuild indicates if the default build script was used. + defaultBuild bool + // env is environment variables to be set. + env []string + // errlog is an abstraction for recording errors to disk. + errlog fsterr.LogInterface + // input is the user's terminal stdin stream + input io.Reader + // manifestFilename is the name of the manifest file. + manifestFilename string + // metadataFilterEnvVars is a comma-separated list of user defined env vars. + metadataFilterEnvVars string + // nonInteractive is the --non-interactive flag. + nonInteractive bool + // output is the users terminal stdout stream + output io.Writer + // postBuild is a custom script executed after the build but before the Wasm + // binary is added to the .tar.gz archive. + postBuild string + // spinner is a terminal progress status indicator. + spinner text.Spinner + // timeout is the build execution threshold. + timeout int + // verbose indicates if the user set --verbose + verbose bool +} + +// DefaultBuildScript indicates if a custom build script was used. +func (j *JavaScript) DefaultBuildScript() bool { + return j.defaultBuild +} + +// JavaScriptPackage represents a package within a JavaScript lockfile. +type JavaScriptPackage struct { + Version string `json:"version"` +} + +// JavaScriptLockFile represents a JavaScript lockfile. +type JavaScriptLockFile struct { + Packages map[string]JavaScriptPackage `json:"packages"` +} + +// Dependencies returns all dependencies used by the project. +func (j *JavaScript) Dependencies() map[string]string { + deps := make(map[string]string) + + lockfile := "npm-shrinkwrap.json" + _, err := os.Stat(lockfile) + if errors.Is(err, os.ErrNotExist) { + lockfile = "package-lock.json" + } + + var jlf JavaScriptLockFile + if f, err := os.Open(lockfile); err == nil { + if err := json.NewDecoder(f).Decode(&jlf); err == nil { + for k, v := range jlf.Packages { + if k != "" { // avoid "root" package + deps[k] = v.Version + } + } + } + } + + return deps +} + +// Build compiles the user's source code into a Wasm binary. +func (j *JavaScript) Build() error { + if j.build == "" { + j.build = JsDefaultBuildCommand + j.defaultBuild = true + + usesWebpack, err := j.checkForWebpack() + if err != nil { + return err + } + if usesWebpack { + j.build = JsDefaultBuildCommandForWebpack + } + } + + if j.defaultBuild && j.verbose { + text.Info(j.output, "No [scripts.build] found in %s. The following default build command for JavaScript will be used: `%s`\n\n", j.manifestFilename, j.build) + } + + bt := BuildToolchain{ + autoYes: j.autoYes, + buildFn: j.Shell.Build, + buildScript: j.build, + env: j.env, + errlog: j.errlog, + in: j.input, + manifestFilename: j.manifestFilename, + metadataFilterEnvVars: j.metadataFilterEnvVars, + nonInteractive: j.nonInteractive, + out: j.output, + postBuild: j.postBuild, + spinner: j.spinner, + timeout: j.timeout, + verbose: j.verbose, + } + + return bt.Build() +} + +func (j JavaScript) checkForWebpack() (bool, error) { + wd, err := os.Getwd() + if err != nil { + return false, err + } + + home, err := os.UserHomeDir() + if err != nil { + return false, err + } + + found, path, err := search("package.json", wd, home) + if err != nil { + return false, err + } + + if found { + // gosec flagged this: + // G304 (CWE-22): Potential file inclusion via variable + // + // Disabling as the path is determined by our own logic. + /* #nosec */ + data, err := os.ReadFile(path) + if err != nil { + return false, err + } + + var pkg NPMPackage + + err = json.Unmarshal(data, &pkg) + if err != nil { + return false, err + } + + for k := range pkg.DevDependencies { + if k == "webpack" { + return true, nil + } + } + + for k := range pkg.Dependencies { + if k == "webpack" { + return true, nil + } + } + } + + return false, nil +} + +// search recurses up the directory tree looking for the given file. +func search(filename, wd, home string) (found bool, path string, err error) { + parent := filepath.Dir(wd) + + var noManifest bool + path = filepath.Join(wd, filename) + if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) { + noManifest = true + } + + // We've found the manifest. + if !noManifest { + return true, path, nil + } + + // NOTE: The first condition catches if we reach the user's 'root' directory. + if wd != parent && wd != home { + return search(filename, parent, home) + } + + return false, "", nil +} + +// NPMPackage represents a package.json manifest and its dependencies. +type NPMPackage struct { + DevDependencies map[string]string `json:"devDependencies"` + Dependencies map[string]string `json:"dependencies"` +} diff --git a/pkg/commands/compute/language_other.go b/pkg/commands/compute/language_other.go new file mode 100644 index 000000000..99c39e2fb --- /dev/null +++ b/pkg/commands/compute/language_other.go @@ -0,0 +1,104 @@ +package compute + +import ( + "io" + + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/text" +) + +// NewOther constructs a new unsupported language instance. +func NewOther( + c *BuildCommand, + in io.Reader, + manifestFilename string, + out io.Writer, + spinner text.Spinner, +) *Other { + return &Other{ + Shell: Shell{}, + + autoYes: c.Globals.Flags.AutoYes, + build: c.Globals.Manifest.File.Scripts.Build, + defaultBuild: false, // there is no default build for 'other' + env: c.Globals.Manifest.File.Scripts.EnvVars, + errlog: c.Globals.ErrLog, + input: in, + manifestFilename: manifestFilename, + metadataFilterEnvVars: c.MetadataFilterEnvVars, + nonInteractive: c.Globals.Flags.NonInteractive, + output: out, + postBuild: c.Globals.Manifest.File.Scripts.PostBuild, + spinner: spinner, + timeout: c.Flags.Timeout, + verbose: c.Globals.Verbose(), + } +} + +// Other implements a Toolchain for languages without official support. +type Other struct { + Shell + + // autoYes is the --auto-yes flag. + autoYes bool + // build is a shell command defined in fastly.toml using [scripts.build]. + build string + // defaultBuild indicates if the default build script was used. + defaultBuild bool + // env is environment variables to be set. + env []string + // errlog is an abstraction for recording errors to disk. + errlog fsterr.LogInterface + // input is the user's terminal stdin stream + input io.Reader + // manifestFilename is the name of the manifest file. + manifestFilename string + // metadataFilterEnvVars is a comma-separated list of user defined env vars. + metadataFilterEnvVars string + // nonInteractive is the --non-interactive flag. + nonInteractive bool + // output is the users terminal stdout stream + output io.Writer + // postBuild is a custom script executed after the build but before the Wasm + // binary is added to the .tar.gz archive. + postBuild string + // spinner is a terminal progress status indicator. + spinner text.Spinner + // timeout is the build execution threshold. + timeout int + // verbose indicates if the user set --verbose + verbose bool +} + +// DefaultBuildScript indicates if a custom build script was used. +func (o Other) DefaultBuildScript() bool { + return o.defaultBuild +} + +// Dependencies returns all dependencies used by the project. +func (o Other) Dependencies() map[string]string { + deps := make(map[string]string) + return deps +} + +// Build implements the Toolchain interface and attempts to compile the package +// source to a Wasm binary. +func (o Other) Build() error { + bt := BuildToolchain{ + autoYes: o.autoYes, + buildFn: o.Shell.Build, + buildScript: o.build, + env: o.env, + errlog: o.errlog, + in: o.input, + manifestFilename: o.manifestFilename, + metadataFilterEnvVars: o.metadataFilterEnvVars, + nonInteractive: o.nonInteractive, + out: o.output, + postBuild: o.postBuild, + spinner: o.spinner, + timeout: o.timeout, + verbose: o.verbose, + } + return bt.Build() +} diff --git a/pkg/commands/compute/language_rust.go b/pkg/commands/compute/language_rust.go new file mode 100644 index 000000000..a6c8ab54f --- /dev/null +++ b/pkg/commands/compute/language_rust.go @@ -0,0 +1,507 @@ +package compute + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + + "github.com/Masterminds/semver/v3" + toml "github.com/pelletier/go-toml" + + "github.com/fastly/cli/pkg/config" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/filesystem" + "github.com/fastly/cli/pkg/text" +) + +// RustDefaultBuildCommand is a build command compiled into the CLI binary so it +// can be used as a fallback for customer's who have an existing Compute project and +// are simply upgrading their CLI version and might not be familiar with the +// changes in the 4.0.0 release with regards to how build logic has moved to the +// fastly.toml manifest. +// +// NOTE: In the 5.x CLI releases we persisted the default to the fastly.toml +// We no longer do that. In 6.x we use the default and just inform the user. +// This makes the experience less confusing as users didn't expect file changes. +const RustDefaultBuildCommand = "cargo build --bin %s --release --target %s --color always" + +// RustDefaultWasmWasiTarget is the expected Rust WasmWasi build target. +const RustDefaultWasmWasiTarget = "wasm32-wasip1" + +// OldRustDefaultWasmWasiTarget was the expected Rust WasmWasi build target before version 11 of the CLI. +const OldRustDefaultWasmWasiTarget = "wasm32-wasi" + +// RustManifest is the manifest file for defining project configuration. +const RustManifest = "Cargo.toml" + +// RustDefaultPackageName is the expected binary create/package name to be built. +const RustDefaultPackageName = "fastly-compute-project" + +// RustSourceDirectory represents the source code directory. +const RustSourceDirectory = "src" + +// NewRust constructs a new Rust toolchain. +func NewRust( + c *BuildCommand, + in io.Reader, + manifestFilename string, + out io.Writer, + spinner text.Spinner, +) *Rust { + return &Rust{ + Shell: Shell{}, + + autoYes: c.Globals.Flags.AutoYes, + build: c.Globals.Manifest.File.Scripts.Build, + config: c.Globals.Config.Language.Rust, + env: c.Globals.Manifest.File.Scripts.EnvVars, + errlog: c.Globals.ErrLog, + input: in, + manifestFilename: manifestFilename, + metadataFilterEnvVars: c.MetadataFilterEnvVars, + nonInteractive: c.Globals.Flags.NonInteractive, + output: out, + postBuild: c.Globals.Manifest.File.Scripts.PostBuild, + spinner: spinner, + timeout: c.Flags.Timeout, + verbose: c.Globals.Verbose(), + } +} + +// Rust implements a Toolchain for the Rust language. +type Rust struct { + Shell + + // autoYes is the --auto-yes flag. + autoYes bool + // build is a shell command defined in fastly.toml using [scripts.build]. + build string + // config is the Rust specific application configuration. + config config.Rust + // defaultBuild indicates if the default build script was used. + defaultBuild bool + // env is environment variables to be set. + env []string + // errlog is an abstraction for recording errors to disk. + errlog fsterr.LogInterface + // input is the user's terminal stdin stream + input io.Reader + // manifestFilename is the name of the manifest file. + manifestFilename string + // metadataFilterEnvVars is a comma-separated list of user defined env vars. + metadataFilterEnvVars string + // nonInteractive is the --non-interactive flag. + nonInteractive bool + // output is the users terminal stdout stream + output io.Writer + // packageName is the resolved package name from the project Cargo.toml + packageName string + // postBuild is a custom script executed after the build but before the Wasm + // binary is added to the .tar.gz archive. + postBuild string + // projectRoot is the root directory where the Cargo.toml is located. + projectRoot string + // spinner is a terminal progress status indicator. + spinner text.Spinner + // timeout is the build execution threshold. + timeout int + // verbose indicates if the user set --verbose + verbose bool +} + +// DefaultBuildScript indicates if a custom build script was used. +func (r *Rust) DefaultBuildScript() bool { + return r.defaultBuild +} + +// CargoLockFilePackage represents a package within a Rust lockfile. +type CargoLockFilePackage struct { + Name string `toml:"name"` + Version string `toml:"version"` +} + +// CargoLockFile represents a Rust lockfile. +type CargoLockFile struct { + Packages []CargoLockFilePackage `toml:"package"` +} + +// Dependencies returns all dependencies used by the project. +func (r *Rust) Dependencies() map[string]string { + deps := make(map[string]string) + + var clf CargoLockFile + if data, err := os.ReadFile("Cargo.lock"); err == nil { + if err := toml.Unmarshal(data, &clf); err == nil { + for _, v := range clf.Packages { + deps[v.Name] = v.Version + } + } + } + + return deps +} + +// Build compiles the user's source code into a Wasm binary. +func (r *Rust) Build() error { + if r.build == "" { + r.build = fmt.Sprintf(RustDefaultBuildCommand, RustDefaultPackageName, RustDefaultWasmWasiTarget) + r.defaultBuild = true + } + + err := r.modifyCargoPackageName(r.defaultBuild) + if err != nil { + return err + } + + if r.defaultBuild && r.verbose { + text.Info(r.output, "No [scripts.build] found in %s. The following default build command for Rust will be used: `%s`\n\n", r.manifestFilename, r.build) + } + + version, err := r.toolchainConstraint() + if err != nil { + return err + } + + if version != nil { + err := r.checkCargoConfigFileName(version) + if err != nil { + return err + } + } + + wasmWasiTarget := r.config.WasmWasiTarget + if wasmWasiTarget != RustDefaultWasmWasiTarget { + return fmt.Errorf("the default build in .fastly/config.toml should produce a %s binary, but was instead set to produce a %s binary", RustDefaultWasmWasiTarget, wasmWasiTarget) + } + + bt := BuildToolchain{ + autoYes: r.autoYes, + buildFn: r.Shell.Build, + buildScript: r.build, + env: r.env, + errlog: r.errlog, + in: r.input, + internalPostBuildCallback: r.ProcessLocation, + manifestFilename: r.manifestFilename, + metadataFilterEnvVars: r.metadataFilterEnvVars, + nonInteractive: r.nonInteractive, + out: r.output, + postBuild: r.postBuild, + spinner: r.spinner, + timeout: r.timeout, + verbose: r.verbose, + } + + return bt.Build() +} + +// RustToolchainManifest models a [toolchain] from a rust-toolchain.toml manifest. +type RustToolchainManifest struct { + Toolchain RustToolchain `toml:"toolchain"` +} + +// RustToolchain models the rust-toolchain targets. +type RustToolchain struct { + Targets []string `toml:"targets"` +} + +// modifyCargoPackageName validates whether the --bin flag matches the +// Cargo.toml package name. If it doesn't match, update the default build script +// to match. +func (r *Rust) modifyCargoPackageName(defaultBuild bool) error { + s := "cargo locate-project --quiet" + args := strings.Split(s, " ") + + var stdout, stderr bytes.Buffer + + // gosec flagged this: + // G204 (CWE-78): Subprocess launched with variable + // Disabling as we control this command. + // #nosec + // nosemgrep + cmd := exec.Command(args[0], args[1:]...) + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + if err != nil { + if stderr.Len() > 0 { + err = fmt.Errorf("%w: %s", err, stderr.String()) + } + return fmt.Errorf("failed to execute command '%s': %w", s, err) + } + + if r.verbose { + text.Output(r.output, "Command output for '%s': %s", s, stdout.String()) + } + + var cp *CargoLocateProject + err = json.Unmarshal(stdout.Bytes(), &cp) + if err != nil { + return fmt.Errorf("failed to unmarshal manifest project root metadata: %w", err) + } + + r.projectRoot = cp.Root + + var m CargoManifest + if err := m.Read(cp.Root); err != nil { + return fmt.Errorf("error reading %s manifest: %w", RustManifest, err) + } + + switch { + case m.Package.Name != "": + // If using standard project structure. + // Cargo.toml won't be a Workspace, so it will contain a package name. + r.packageName = m.Package.Name + case len(m.Workspace.Members) > 0 && defaultBuild: + // If user has a Cargo Workspace AND no custom script. + // We need to identify which Workspace package is their application. + // Then extract the package name from its Cargo.toml manifest. + // We do this by checking for a rust-toolchain.toml containing the proper target. + // + // NOTE: This logic will need to change in the future. + // Specifically, when we support linking multiple Wasm binaries. + for _, m := range m.Workspace.Members { + var rtm RustToolchainManifest + rustToolchainFile := "rust-toolchain.toml" + data, err := os.ReadFile(filepath.Join(m, rustToolchainFile)) // #nosec G304 (CWE-22) + if err != nil { + return err + } + err = toml.Unmarshal(data, &rtm) + if err != nil { + return fmt.Errorf("failed to unmarshal '%s' data: %w", rustToolchainFile, err) + } + if len(rtm.Toolchain.Targets) > 0 { + if rtm.Toolchain.Targets[0] == RustDefaultWasmWasiTarget { + var cm CargoManifest + err := cm.Read(filepath.Join(m, "Cargo.toml")) + if err != nil { + return err + } + r.packageName = cm.Package.Name + } else { + return fmt.Errorf("please consult https://www.fastly.com/documentation/guides/compute/#install-language-tooling to configure your toolchain correctly") + } + } + } + case len(m.Workspace.Members) > 0 && !defaultBuild: + // If user has a Cargo Workspace AND a custom script. + // Trust their custom script aligns with the relevant Workspace package name. + // i.e. we parse the package name specified in their custom script. + parts := strings.Split(r.build, " ") + for i, p := range parts { + if p == "--bin" { + r.packageName = parts[i+1] + break + } + } + } + + // Ensure the default build script matches the Cargo.toml package name. + if defaultBuild && r.packageName != "" && r.packageName != RustDefaultPackageName { + r.build = fmt.Sprintf(RustDefaultBuildCommand, r.packageName, RustDefaultWasmWasiTarget) + } + + return nil +} + +// toolchainConstraint generates an error if the toolchain constraint is not met. +func (r *Rust) toolchainConstraint() (*semver.Version, error) { + if r.verbose { + text.Info(r.output, "The Fastly CLI requires a Rust version '%s'.\n\n", r.config.ToolchainConstraint) + } + + versionCommand := "cargo version --quiet" + args := strings.Split(versionCommand, " ") + + // gosec flagged this: + // G204 (CWE-78): Subprocess launched with function call as argument or cmd arguments + // Disabling as we trust the source of the variable. + // #nosec + // nosemgrep + cmd := exec.Command(args[0], args[1:]...) + stdout, err := cmd.Output() + output := string(stdout) + if err != nil { + return nil, err + } + + versionPattern := regexp.MustCompile(`cargo (?P\d[^\s]+)`) + match := versionPattern.FindStringSubmatch(output) + if len(match) < 2 { // We expect a pattern with one capture group. + return nil, fmt.Errorf("unable to obtain a version number from the 'cargo' command") + } + version := match[1] + + v, err := semver.NewVersion(version) + if err != nil { + return nil, fmt.Errorf("the version string '%s' reported by the 'cargo' command is not a valid version number", version) + } + + c, err := semver.NewConstraint(r.config.ToolchainConstraint) + if err != nil { + return nil, fmt.Errorf("the 'toolchain_constraint' value '%s' (from the config.toml file) is not a valid version constraint", r.config.ToolchainConstraint) + } + + valid, errs := c.Validate(v) + if !valid { + err = nil + for _, e := range errs { + // if an 'upper bound' constraint was + // violated, generate an error message + // specific to that situation + if strings.Contains(e.Error(), "is greater than") { + err = fmt.Errorf("version '%s' of Rust has not been validated for use with Fastly Compute", v) + } + } + if err == nil { + err = fmt.Errorf("the Rust version requirement was not satisfied: '%w'", errors.Join(errs...)) + } + return nil, fsterr.RemediationError{ + Inner: err, + Remediation: "Consult the Rust guide for Compute at https://www.fastly.com/documentation/guides/compute/rust/ for more information.", + } + } + + return v, nil +} + +func (r *Rust) checkCargoConfigFileName(rustVersion *semver.Version) error { + dir, err := os.Getwd() + if err != nil { + r.errlog.Add(err) + return fmt.Errorf("getting current working directory: %w", err) + } + + if !filesystem.FileExists(filepath.Join(dir, ".cargo", "config")) { + return nil + } + + filenameMsg := "\nThe Cargo configuration file name is .cargo/config" + + c, _ := semver.NewConstraint(">=1.78.0") + + if c.Check(rustVersion) { + text.Warning(r.output, filenameMsg) + return fmt.Errorf("the build cannot proceed with Rust version '%s' as the file must be named .cargo/config.toml", rustVersion) + } + + text.Warning(r.output, filenameMsg+". The file should be renamed to .cargo/config.toml to be compatible with Rust 1.78.0 or later\n\n") + return nil +} + +// ProcessLocation ensures the generated Rust Wasm binary is moved to the +// required location for packaging. +func (r *Rust) ProcessLocation() error { + dir, err := os.Getwd() + if err != nil { + r.errlog.Add(err) + return fmt.Errorf("getting current working directory: %w", err) + } + + var metadata CargoMetadata + if err := metadata.Read(r.errlog); err != nil { + r.errlog.Add(err) + return fmt.Errorf("error reading cargo metadata: %w", err) + } + + src := filepath.Join(metadata.TargetDirectory, r.config.WasmWasiTarget, "release", fmt.Sprintf("%s.wasm", r.packageName)) + dst := filepath.Join(dir, "bin", "main.wasm") + + err = filesystem.CopyFile(src, dst) + if err != nil { + // check for the binary in the 'old' location before + // the compilation target name was changed + src := filepath.Join(metadata.TargetDirectory, OldRustDefaultWasmWasiTarget, "release", fmt.Sprintf("%s.wasm", r.packageName)) + if filesystem.FileExists(src) { + return fmt.Errorf("this project is configured to produce a '%s' target, but the Fastly CLI requires the '%s' target.\nTo reconfigure your project, follow the instructions at https://www.fastly.com/documentation/guides/compute/rust/#using-fastly-cli-1100-or-higher", OldRustDefaultWasmWasiTarget, r.config.WasmWasiTarget) + } + + r.errlog.Add(err) + return fmt.Errorf("failed to copy wasm binary: %w", err) + } + return nil +} + +// CargoLocateProject represents the metadata for where to find the project's +// Cargo.toml manifest file. +type CargoLocateProject struct { + Root string `json:"root"` +} + +// CargoManifest models the package configuration properties of a Rust Cargo +// manifest which we are interested in and are read from the Cargo.toml manifest +// file within the $PWD of the package. +type CargoManifest struct { + Package CargoPackage `toml:"package"` + Workspace CargoWorkspace `toml:"workspace"` +} + +// Read the contents of the Cargo.toml manifest from filename. +func (m *CargoManifest) Read(path string) error { + // gosec flagged this: + // G304 (CWE-22): Potential file inclusion via variable. + // Disabling as we need to load the Cargo.toml from the user's file system. + // This file is decoded into a predefined struct, any unrecognised fields are dropped. + // #nosec + data, err := os.ReadFile(path) + if err != nil { + return err + } + return toml.Unmarshal(data, m) +} + +// CargoWorkspace models the [workspace] config inside Cargo.toml. +type CargoWorkspace struct { + Members []string `toml:"members" json:"members"` +} + +// CargoPackage models the package configuration properties of a Rust Cargo +// package which we are interested in and is embedded within CargoManifest and +// CargoLock. +type CargoPackage struct { + Name string `toml:"name" json:"name"` + Version string `toml:"version" json:"version"` +} + +// CargoMetadata models information about the workspace members and resolved +// dependencies of the current package via `cargo metadata` command output. +type CargoMetadata struct { + Package []CargoMetadataPackage `json:"packages"` + TargetDirectory string `json:"target_directory"` +} + +// Read the contents of the Cargo.lock file from filename. +func (m *CargoMetadata) Read(errlog fsterr.LogInterface) error { + cmd := exec.Command("cargo", "metadata", "--quiet", "--format-version", "1") + stdoutStderr, err := cmd.CombinedOutput() + if err != nil { + if len(stdoutStderr) > 0 { + err = fmt.Errorf("%s", strings.TrimSpace(string(stdoutStderr))) + } + errlog.Add(err) + return err + } + r := bytes.NewReader(stdoutStderr) + if err := json.NewDecoder(r).Decode(&m); err != nil { + errlog.Add(err) + return err + } + return nil +} + +// CargoMetadataPackage models the package structure returned when executing +// the command `cargo metadata`. +type CargoMetadataPackage struct { + Name string `toml:"name" json:"name"` + Version string `toml:"version" json:"version"` + Dependencies []CargoMetadataPackage `toml:"dependencies" json:"dependencies"` +} diff --git a/pkg/commands/compute/language_toolchain.go b/pkg/commands/compute/language_toolchain.go new file mode 100644 index 000000000..695a8e81d --- /dev/null +++ b/pkg/commands/compute/language_toolchain.go @@ -0,0 +1,340 @@ +package compute + +import ( + "bytes" + "encoding/binary" + "fmt" + "io" + "os" + "strconv" + "strings" + + fsterr "github.com/fastly/cli/pkg/errors" + fstexec "github.com/fastly/cli/pkg/exec" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/text" +) + +const ( + // https://webassembly.github.io/spec/core/binary/modules.html#binary-module + wasmBytes = 4 + + // Defining as a constant avoids gosec G304 issue with command execution. + binWasmPath = "./bin/main.wasm" +) + +// DefaultBuildErrorRemediation is the message returned to a user when there is +// a build error. +var DefaultBuildErrorRemediation = func() string { + return fmt.Sprintf(`%s: + +- Re-run the fastly command with the --verbose flag to see more information. +- Is the required language toolchain (node/npm, rust/cargo etc) installed correctly? +- Is the required version (if any) of the language toolchain installed/activated? +- Were the required dependencies (package.json, Cargo.toml etc) installed? +- Did the build script (see fastly.toml [scripts.build]) produce a ./bin/main.wasm binary file? +- Was there a configured [scripts.post_build] step that needs to be double-checked? + +For more information on fastly.toml configuration settings, refer to https://www.fastly.com/documentation/reference/compute/fastly-toml`, + text.BoldYellow("Here are some steps you can follow to debug the issue")) +}() + +// Toolchain abstracts a Compute source language toolchain. +type Toolchain interface { + // Build compiles the user's source code into a Wasm binary. + Build() error + // DefaultBuildScript indicates if a default build script was used. + DefaultBuildScript() bool + // Dependencies returns all dependencies used by the project. + Dependencies() map[string]string +} + +// BuildToolchain enables a language toolchain to compile their build script. +type BuildToolchain struct { + // autoYes is the --auto-yes flag. + autoYes bool + // buildFn constructs a `sh -c` command from the buildScript. + buildFn func(string) (string, []string) + // buildScript is the [scripts.build] within the fastly.toml manifest. + buildScript string + // env is environment variables to be set. + env []string + // errlog is an abstraction for recording errors to disk. + errlog fsterr.LogInterface + // in is the user's terminal stdin stream + in io.Reader + // internalPostBuildCallback is run after the build but before post build. + internalPostBuildCallback func() error + // manifestFilename is the name of the manifest file. + manifestFilename string + // metadataFilterEnvVars is a comma-separated list of user defined env vars. + metadataFilterEnvVars string + // nonInteractive is the --non-interactive flag. + nonInteractive bool + // out is the users terminal stdout stream + out io.Writer + // postBuild is a custom script executed after the build but before the Wasm + // binary is added to the .tar.gz archive. + postBuild string + // spinner is a terminal progress status indicator. + spinner text.Spinner + // timeout is the build execution threshold. + timeout int + // verbose indicates if the user set --verbose + verbose bool +} + +// Build compiles the user's source code into a Wasm binary. +func (bt BuildToolchain) Build() error { + // Make sure to delete any pre-existing binary otherwise prior metadata will + // continue to be persisted. + if _, err := os.Stat(binWasmPath); err == nil { + os.Remove(binWasmPath) + } + + cmd, args := bt.buildFn(bt.buildScript) + + if bt.verbose { + buildScript := fmt.Sprintf("%s %s", cmd, strings.Join(args, " ")) + text.Description(bt.out, "Build script to execute", FilterSecretsFromString(buildScript)) + + // IMPORTANT: We filter secrets the best we can before printing env vars. + // We use two separate processes to do this. + // First is filtering based on known environment variables. + // Second is filtering based on a generalised regex pattern. + if len(bt.env) > 0 { + ExtendStaticSecretEnvVars(bt.metadataFilterEnvVars) + s := strings.Join(bt.env, " ") + text.Description(bt.out, "Build environment variables set", FilterSecretsFromString(s)) + } + } + + var err error + msg := "Running [scripts.build]" + + // If we're in verbose mode, the build output is shown. + // So in that case we don't want to have a spinner as it'll interweave output. + // In non-verbose mode we have a spinner running while the build is happening. + if !bt.verbose { + err = bt.spinner.Start() + if err != nil { + return err + } + bt.spinner.Message(msg + "...") + } + + err = bt.execCommand(cmd, args, msg) + if err != nil { + // In verbose mode we'll have the failure status AFTER the error output. + // But we can't just call StopFailMessage() without first starting the spinner. + if bt.verbose { + text.Break(bt.out) + spinErr := bt.spinner.Start() + if spinErr != nil { + return fmt.Errorf(text.SpinnerErrWrapper, spinErr, err) + } + bt.spinner.Message(msg + "...") + bt.spinner.StopFailMessage(msg) + spinErr = bt.spinner.StopFail() + if spinErr != nil { + return fmt.Errorf(text.SpinnerErrWrapper, spinErr, err) + } + } + // WARNING: Don't try to add 'StopFailMessage/StopFail' calls here. + // If we're in non-verbose mode, then the spinner is BEFORE the error output. + // Also, in non-verbose mode stopping the spinner is handled internally. + // See the call to StopFailMessage() inside fstexec.Streaming.Exec(). + return bt.handleError(err) + } + + // In verbose mode we'll have the failure status AFTER the error output. + // But we can't just call StopMessage() without first starting the spinner. + if bt.verbose { + err = bt.spinner.Start() + if err != nil { + return err + } + bt.spinner.Message(msg + "...") + text.Break(bt.out) + } + + bt.spinner.StopMessage(msg) + err = bt.spinner.Stop() + if err != nil { + return err + } + + // NOTE: internalPostBuildCallback is only used by Rust currently. + // It's not a step that would be configured by a user in their fastly.toml + // It enables Rust to move the compiled binary to a different location. + // This has to happen BEFORE the postBuild step. + if bt.internalPostBuildCallback != nil { + err := bt.internalPostBuildCallback() + if err != nil { + return bt.handleError(err) + } + } + + // IMPORTANT: The stat check MUST come after the internalPostBuildCallback. + // This is because for Rust it needs to move the binary first. + _, err = os.Stat(binWasmPath) + if err != nil { + return bt.handleError(err) + } + // NOTE: The logic for checking the Wasm binary is 'valid' is not exhaustive. + if err := bt.validateWasm(); err != nil { + return err + } + + if bt.postBuild != "" { + if !bt.autoYes && !bt.nonInteractive { + manifestFilename := bt.manifestFilename + if manifestFilename == "" { + manifestFilename = manifest.Filename + } + msg := fmt.Sprintf(CustomPostScriptMessage, "build", manifestFilename) + err := bt.promptForPostBuildContinue(msg, bt.postBuild, bt.out, bt.in) + if err != nil { + return err + } + } + + // If we're in verbose mode, the build output is shown. + // So in that case we don't want to have a spinner as it'll interweave output. + // In non-verbose mode we have a spinner running while the build is happening. + if !bt.verbose { + err = bt.spinner.Start() + if err != nil { + return err + } + msg = "Running [scripts.post_build]..." + bt.spinner.Message(msg) + } + + cmd, args := bt.buildFn(bt.postBuild) + err := bt.execCommand(cmd, args, msg) + if err != nil { + // In verbose mode we'll have the failure status AFTER the error output. + // But we can't just call StopFailMessage() without first starting the spinner. + if bt.verbose { + text.Break(bt.out) + spinErr := bt.spinner.Start() + if spinErr != nil { + return fmt.Errorf(text.SpinnerErrWrapper, spinErr, err) + } + bt.spinner.Message(msg + "...") + bt.spinner.StopFailMessage(msg) + spinErr = bt.spinner.StopFail() + if spinErr != nil { + return fmt.Errorf(text.SpinnerErrWrapper, spinErr, err) + } + } + // WARNING: Don't try to add 'StopFailMessage/StopFail' calls here. + // It is handled internally by fstexec.Streaming.Exec(). + return bt.handleError(err) + } + + // In verbose mode we'll have the failure status AFTER the error output. + // But we can't just call StopMessage() without first starting the spinner. + if bt.verbose { + err = bt.spinner.Start() + if err != nil { + return err + } + bt.spinner.Message(msg + "...") + text.Break(bt.out) + } + + bt.spinner.StopMessage(msg) + err = bt.spinner.Stop() + if err != nil { + return err + } + } + + return nil +} + +// The encoding of a module starts with a preamble containing a 4-byte magic +// number (the string '\0asm') and a version field. +// +// Reference: +// https://webassembly.github.io/spec/core/binary/modules.html#binary-module +func (bt BuildToolchain) validateWasm() error { + f, err := os.Open(binWasmPath) + if err != nil { + return bt.handleError(err) + } + defer f.Close() + + // Parse the magic number + magic := make([]byte, wasmBytes) + _, err = f.Read(magic) + if err != nil { + return bt.handleError(err) + } + expectedMagic := []byte{0x00, 0x61, 0x73, 0x6d} + if !bytes.Equal(magic, expectedMagic) { + return bt.handleError(fmt.Errorf("unexpected magic: %#v", magic)) + } + if bt.verbose { + text.Break(bt.out) + text.Description(bt.out, "Wasm module 'magic'", fmt.Sprintf("%#v", magic)) + } + + // Parse the version + var version uint32 + if err := binary.Read(f, binary.LittleEndian, &version); err != nil { + return bt.handleError(err) + } + if bt.verbose { + text.Description(bt.out, "Wasm module 'version'", strconv.FormatUint(uint64(version), 10)) + } + return nil +} + +func (bt BuildToolchain) handleError(err error) error { + return fsterr.RemediationError{ + Inner: err, + Remediation: DefaultBuildErrorRemediation, + } +} + +// execCommand opens a sub shell to execute the language build script. +// +// NOTE: We pass the spinner and associated message to handle error cases. +// This avoids an issue where the spinner is still running when an error occurs. +// When the error occurs the command output is displayed. +// This causes the spinner message to be displayed twice with different status. +// By passing in the spinner and message we can short-circuit the spinner. +func (bt BuildToolchain) execCommand(cmd string, args []string, spinMessage string) error { + return fstexec.Command(fstexec.CommandOpts{ + Args: args, + Command: cmd, + Env: bt.env, + ErrLog: bt.errlog, + Output: bt.out, + Spinner: bt.spinner, + SpinnerMessage: spinMessage, + Timeout: bt.timeout, + Verbose: bt.verbose, + }) +} + +// promptForPostBuildContinue ensures the user is happy to continue with the build +// when there is a post_build in the fastly.toml manifest file. +func (bt BuildToolchain) promptForPostBuildContinue(msg, script string, out io.Writer, in io.Reader) error { + text.Info(out, "%s:\n", msg) + text.Indent(out, 4, "%s", script) + + label := "\nDo you want to run this now? [y/N] " + answer, err := text.AskYesNo(out, label, in) + if err != nil { + return err + } + if !answer { + return fsterr.ErrPostBuildStopped + } + text.Break(out) + return nil +} diff --git a/pkg/commands/compute/metadata.go b/pkg/commands/compute/metadata.go new file mode 100644 index 000000000..b210856f6 --- /dev/null +++ b/pkg/commands/compute/metadata.go @@ -0,0 +1,133 @@ +package compute + +import ( + "fmt" + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/config" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// MetadataCommand controls what metadata is collected for a Wasm binary. +type MetadataCommand struct { + argparser.Base + + disable bool + disableBuild bool + disableMachine bool + disablePackage bool + disableScript bool + enable bool + enableBuild bool + enableMachine bool + enablePackage bool + enableScript bool +} + +// NewMetadataCommand returns a new command registered in the parent. +func NewMetadataCommand(parent argparser.Registerer, g *global.Data) *MetadataCommand { + var c MetadataCommand + c.Globals = g + c.CmdClause = parent.Command("metadata", "Control what metadata is collected") + c.CmdClause.Flag("disable", "Disable all metadata").BoolVar(&c.disable) + c.CmdClause.Flag("disable-build", "Disable metadata for information regarding the time taken for builds and compilation processes").BoolVar(&c.disableBuild) + c.CmdClause.Flag("disable-machine", "Disable metadata for general, non-identifying system specifications (CPU, RAM, operating system)").BoolVar(&c.disableMachine) + c.CmdClause.Flag("disable-package", "Disable metadata for packages and libraries utilized in your source code").BoolVar(&c.disablePackage) + c.CmdClause.Flag("disable-script", "Disable metadata for script info from the fastly.toml manifest (i.e. [scripts] section).").BoolVar(&c.disableScript) + c.CmdClause.Flag("enable", "Enable all metadata").BoolVar(&c.enable) + c.CmdClause.Flag("enable-build", "Enable metadata for information regarding the time taken for builds and compilation processes").BoolVar(&c.enableBuild) + c.CmdClause.Flag("enable-machine", "Enable metadata for general, non-identifying system specifications (CPU, RAM, operating system)").BoolVar(&c.enableMachine) + c.CmdClause.Flag("enable-package", "Enable metadata for packages and libraries utilized in your source code").BoolVar(&c.enablePackage) + c.CmdClause.Flag("enable-script", "Enable metadata for script info from the fastly.toml manifest (i.e. [scripts] section).").BoolVar(&c.enableScript) + return &c +} + +// Exec implements the command interface. +func (c *MetadataCommand) Exec(_ io.Reader, out io.Writer) error { + if c.disable && c.enable { + return fsterr.ErrInvalidEnableDisableFlagCombo + } + + var modified bool + + // Global enable/disable + if c.enable { + c.Globals.Config.WasmMetadata = toggleAll("enable") + modified = true + } + if c.disable { + c.Globals.Config.WasmMetadata = toggleAll("disable") + modified = true + } + + // Specific enablement + if c.enableBuild { + c.Globals.Config.WasmMetadata.BuildInfo = "enable" + modified = true + } + if c.enableMachine { + c.Globals.Config.WasmMetadata.MachineInfo = "enable" + modified = true + } + if c.enablePackage { + c.Globals.Config.WasmMetadata.PackageInfo = "enable" + modified = true + } + if c.enableScript { + c.Globals.Config.WasmMetadata.ScriptInfo = "enable" + modified = true + } + + // Specific disablement + if c.disableBuild { + c.Globals.Config.WasmMetadata.BuildInfo = "disable" + modified = true + } + if c.disableMachine { + c.Globals.Config.WasmMetadata.MachineInfo = "disable" + modified = true + } + if c.disablePackage { + c.Globals.Config.WasmMetadata.PackageInfo = "disable" + modified = true + } + if c.disableScript { + c.Globals.Config.WasmMetadata.ScriptInfo = "disable" + modified = true + } + + if modified { + if c.disable && (c.enableBuild || c.enableMachine || c.enablePackage || c.enableScript) { + text.Info(out, "We will disable all metadata except for the specified `--enable-*` flags") + text.Break(out) + } + if c.enable && (c.disableBuild || c.disableMachine || c.disablePackage || c.disableScript) { + text.Info(out, "We will enable all metadata except for the specified `--disable-*` flags") + text.Break(out) + } + err := c.Globals.Config.Write(c.Globals.ConfigPath) + if err != nil { + return fmt.Errorf("failed to persist metadata choices to disk: %w", err) + } + text.Success(out, "configuration updated") + text.Break(out) + } + + text.Output(out, "Build Information: %s", c.Globals.Config.WasmMetadata.BuildInfo) + text.Output(out, "Machine Information: %s", c.Globals.Config.WasmMetadata.MachineInfo) + text.Output(out, "Package Information: %s", c.Globals.Config.WasmMetadata.PackageInfo) + text.Output(out, "Script Information: %s", c.Globals.Config.WasmMetadata.ScriptInfo) + return nil +} + +func toggleAll(state string) config.WasmMetadata { + var t config.WasmMetadata + t.BuildInfo = state + t.MachineInfo = state + t.PackageInfo = state + t.ScriptInfo = state + return t +} diff --git a/pkg/commands/compute/metadata_test.go b/pkg/commands/compute/metadata_test.go new file mode 100644 index 000000000..3d2be8ba0 --- /dev/null +++ b/pkg/commands/compute/metadata_test.go @@ -0,0 +1,175 @@ +package compute_test + +import ( + "os" + "path/filepath" + "testing" + + toml "github.com/pelletier/go-toml" + + root "github.com/fastly/cli/pkg/commands/compute" + "github.com/fastly/cli/pkg/config" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/revision" + "github.com/fastly/cli/pkg/testutil" + "github.com/fastly/cli/pkg/threadsafe" +) + +func TestMetadata(t *testing.T) { + // We read the static/embedded config so we can get the latest config + // version and so we don't accidentally switch to the UseStatic() version. + var staticConfig config.File + err := toml.Unmarshal(config.Static, &staticConfig) + if err != nil { + t.Error(err) + } + + scenarios := []testutil.CLIScenario{ + { + Args: "--enable", + ConfigFile: &config.File{ + ConfigVersion: staticConfig.ConfigVersion, + CLI: config.CLI{ + Version: revision.SemVer(revision.AppVersion), + }, + }, + Env: &testutil.EnvConfig{ + Opts: &testutil.EnvOpts{ + Copy: []testutil.FileIO{ + { + Src: filepath.Join("testdata", "metadata", "config.toml"), + Dst: "config.toml", + }, + }, + }, + EditScenario: func(scenario *testutil.CLIScenario, rootdir string) { + scenario.ConfigPath = filepath.Join(rootdir, "config.toml") + }, + }, + WantOutput: "SUCCESS: configuration updated", + Validator: func(t *testing.T, _ *testutil.CLIScenario, opts *global.Data, _ *threadsafe.Buffer) { + data, err := os.ReadFile(opts.ConfigPath) + if err != nil { + t.Error(err) + } + + var testFile config.File + unmarshalErr := toml.Unmarshal(data, &testFile) + if unmarshalErr != nil { + t.Error(unmarshalErr) + } + + testutil.AssertString(t, "enable", testFile.WasmMetadata.BuildInfo) + testutil.AssertString(t, "enable", testFile.WasmMetadata.MachineInfo) + testutil.AssertString(t, "enable", testFile.WasmMetadata.PackageInfo) + }, + }, + { + Args: "--disable", + Env: &testutil.EnvConfig{ + Opts: &testutil.EnvOpts{ + Copy: []testutil.FileIO{ + { + Src: filepath.Join("testdata", "metadata", "config.toml"), + Dst: "config.toml", + }, + }, + }, + EditScenario: func(scenario *testutil.CLIScenario, rootdir string) { + scenario.ConfigPath = filepath.Join(rootdir, "config.toml") + }, + }, + WantOutput: "SUCCESS: configuration updated", + Validator: func(t *testing.T, _ *testutil.CLIScenario, opts *global.Data, _ *threadsafe.Buffer) { + data, err := os.ReadFile(opts.ConfigPath) + if err != nil { + t.Error(err) + } + + var testFile config.File + unmarshalErr := toml.Unmarshal(data, &testFile) + if unmarshalErr != nil { + t.Error(unmarshalErr) + } + + testutil.AssertString(t, "disable", testFile.WasmMetadata.BuildInfo) + testutil.AssertString(t, "disable", testFile.WasmMetadata.MachineInfo) + testutil.AssertString(t, "disable", testFile.WasmMetadata.PackageInfo) + }, + }, + { + Args: "--enable --disable-build", + Env: &testutil.EnvConfig{ + Opts: &testutil.EnvOpts{ + Copy: []testutil.FileIO{ + { + Src: filepath.Join("testdata", "metadata", "config.toml"), + Dst: "config.toml", + }, + }, + }, + EditScenario: func(scenario *testutil.CLIScenario, rootdir string) { + scenario.ConfigPath = filepath.Join(rootdir, "config.toml") + }, + }, + WantOutputs: []string{ + "INFO: We will enable all metadata except for the specified `--disable-*` flags", + "SUCCESS: configuration updated", + }, + Validator: func(t *testing.T, _ *testutil.CLIScenario, opts *global.Data, _ *threadsafe.Buffer) { + data, err := os.ReadFile(opts.ConfigPath) + if err != nil { + t.Error(err) + } + + var testFile config.File + unmarshalErr := toml.Unmarshal(data, &testFile) + if unmarshalErr != nil { + t.Error(unmarshalErr) + } + + testutil.AssertString(t, "disable", testFile.WasmMetadata.BuildInfo) + testutil.AssertString(t, "enable", testFile.WasmMetadata.MachineInfo) + testutil.AssertString(t, "enable", testFile.WasmMetadata.PackageInfo) + }, + }, + { + Args: "--disable --enable-machine", + Env: &testutil.EnvConfig{ + Opts: &testutil.EnvOpts{ + Copy: []testutil.FileIO{ + { + Src: filepath.Join("testdata", "metadata", "config.toml"), + Dst: "config.toml", + }, + }, + }, + EditScenario: func(scenario *testutil.CLIScenario, rootdir string) { + scenario.ConfigPath = filepath.Join(rootdir, "config.toml") + }, + }, + WantOutputs: []string{ + "INFO: We will disable all metadata except for the specified `--enable-*` flags", + "SUCCESS: configuration updated", + }, + Validator: func(t *testing.T, _ *testutil.CLIScenario, opts *global.Data, _ *threadsafe.Buffer) { + data, err := os.ReadFile(opts.ConfigPath) + if err != nil { + t.Error(err) + } + + var testFile config.File + unmarshalErr := toml.Unmarshal(data, &testFile) + if unmarshalErr != nil { + t.Error(unmarshalErr) + } + + testutil.AssertString(t, "disable", testFile.WasmMetadata.BuildInfo) + testutil.AssertString(t, "enable", testFile.WasmMetadata.MachineInfo) + testutil.AssertString(t, "disable", testFile.WasmMetadata.PackageInfo) + }, + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, "metadata"}, scenarios) +} diff --git a/pkg/commands/compute/pack.go b/pkg/commands/compute/pack.go new file mode 100644 index 000000000..8ff0acf12 --- /dev/null +++ b/pkg/commands/compute/pack.go @@ -0,0 +1,174 @@ +package compute + +import ( + "fmt" + "io" + "os" + "path/filepath" + + "github.com/kennygrant/sanitize" + "github.com/mholt/archiver/v3" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/filesystem" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/text" +) + +// PackCommand takes a .wasm and builds the required tar/gzip package ready to be uploaded. +type PackCommand struct { + argparser.Base + wasmBinary string +} + +// NewPackCommand returns a usable command registered under the parent. +func NewPackCommand(parent argparser.Registerer, g *global.Data) *PackCommand { + var c PackCommand + c.Globals = g + c.CmdClause = parent.Command("pack", "Package a pre-compiled Wasm binary for a Fastly Compute service") + c.CmdClause.Flag("wasm-binary", "Path to a pre-compiled Wasm binary").Short('w').Required().StringVar(&c.wasmBinary) + + return &c +} + +// Exec implements the command interface. +// +// NOTE: The bin/manifest is placed in a 'package' folder within the tar.gz. +func (c *PackCommand) Exec(_ io.Reader, out io.Writer) (err error) { + spinner, err := text.NewSpinner(out) + if err != nil { + return err + } + + filename := sanitize.BaseName(c.Globals.Manifest.File.Name) + if filename == "" { + filename = "package" + } + + defer func(errLog fsterr.LogInterface) { + _ = os.RemoveAll(fmt.Sprintf("pkg/%s", filename)) + if err != nil { + errLog.Add(err) + } + }(c.Globals.ErrLog) + + if err = c.Globals.Manifest.File.ReadError(); err != nil { + return err + } + + bin := fmt.Sprintf("pkg/%s/bin/main.wasm", filename) + bindir := filepath.Dir(bin) + + err = filesystem.MakeDirectoryIfNotExists(bindir) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Wasm directory (relative)": bindir, + }) + return err + } + + src, err := filepath.Abs(c.wasmBinary) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Path (absolute)": src, + }) + return err + } + + dst, err := filepath.Abs(bin) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Wasm destination (relative)": bin, + }) + return err + } + + err = spinner.Process("Copying wasm binary", func(_ *text.SpinnerWrapper) error { + if err := filesystem.CopyFile(src, dst); err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Path (absolute)": src, + "Wasm destination (absolute)": dst, + }) + return fmt.Errorf("error copying wasm binary to '%s': %w", dst, err) + } + + if !filesystem.FileExists(bin) { + return fsterr.RemediationError{ + Inner: fmt.Errorf("no wasm binary found"), + Remediation: "Run `fastly compute pack --path ` to copy your wasm binary to the required location", + } + } + + src = manifest.Filename + dst = fmt.Sprintf("pkg/%s/%s", filename, manifest.Filename) + if err := filesystem.CopyFile(src, dst); err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Manifest (destination)": dst, + "Manifest (source)": src, + }) + return fmt.Errorf("error copying manifest to '%s': %w", dst, err) + } + + tar := archiver.NewTarGz() + tar.OverwriteExisting = true + { + dir := fmt.Sprintf("pkg/%s", filename) + src := []string{dir} + dst := fmt.Sprintf("%s.tar.gz", dir) + if err = tar.Archive(src, dst); err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Path (absolute)": src, + "Wasm destination (absolute)": dst, + }) + return fmt.Errorf("error copying wasm binary to '%s': %w", dst, err) + } + + if !filesystem.FileExists(bin) { + return fsterr.RemediationError{ + Inner: fmt.Errorf("no wasm binary found"), + Remediation: "Run `fastly compute pack --path ` to copy your wasm binary to the required location", + } + } + return nil + } + }) + if err != nil { + return err + } + + err = spinner.Process("Copying manifest", func(_ *text.SpinnerWrapper) error { + src = manifest.Filename + dst = fmt.Sprintf("pkg/package/%s", manifest.Filename) + if err := filesystem.CopyFile(src, dst); err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Manifest (destination)": dst, + "Manifest (source)": src, + }) + return fmt.Errorf("error copying manifest to '%s': %w", dst, err) + } + return nil + }) + if err != nil { + return err + } + + return spinner.Process(fmt.Sprintf("Creating %s.tar.gz file", filename), func(_ *text.SpinnerWrapper) error { + tar := archiver.NewTarGz() + tar.OverwriteExisting = true + { + dir := "pkg/package" + src := []string{dir} + dst := fmt.Sprintf("%s.tar.gz", dir) + if err = tar.Archive(src, dst); err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Tar source": dir, + "Tar destination": dst, + }) + return err + } + } + return nil + }) +} diff --git a/pkg/commands/compute/pack_test.go b/pkg/commands/compute/pack_test.go new file mode 100644 index 000000000..ddc64c262 --- /dev/null +++ b/pkg/commands/compute/pack_test.go @@ -0,0 +1,107 @@ +package compute_test + +import ( + "bytes" + "io" + "os" + "path/filepath" + "testing" + + "github.com/fastly/cli/pkg/app" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/testutil" +) + +func TestPack(t *testing.T) { + args := testutil.SplitArgs + for _, testcase := range []struct { + name string + args []string + manifest string + wantError string + wantOutput []string + expectedFiles [][]string + }{ + { + name: "success", + args: args("compute pack --wasm-binary ./main.wasm"), + manifest: ` + manifest_version = 2 + name = "mypackagename"`, + wantOutput: []string{ + "Copying wasm binary", + "Copying manifest", + "Creating mypackagename.tar.gz file", + }, + expectedFiles: [][]string{ + {"pkg", "mypackagename.tar.gz"}, + }, + }, + { + name: "no wasm binary path flag", + args: args("compute pack"), + manifest: `name = "precompiled"`, + wantError: "error parsing arguments: required flag --wasm-binary not provided", + }, + { + name: "no wasm binary path flag value", + args: args("compute pack --wasm-binary "), + manifest: ` + manifest_version = 2 + name = "precompiled"`, + wantError: "error copying wasm binary", + }, + } { + t.Run(testcase.name, func(t *testing.T) { + // We're going to chdir to a test environment, + // so save the PWD to return to, afterwards. + pwd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + + // Create test environment + rootdir := testutil.NewEnv(testutil.EnvOpts{ + T: t, + Copy: []testutil.FileIO{ + {Src: filepath.Join("testdata", "pack", "main.wasm"), Dst: "main.wasm"}, + }, + Write: []testutil.FileIO{ + {Src: testcase.manifest, Dst: manifest.Filename}, + }, + }) + defer os.RemoveAll(rootdir) + + // Before running the test, chdir into the build environment. + // When we're done, chdir back to our original location. + if err := os.Chdir(rootdir); err != nil { + t.Fatal(err) + } + defer func() { + _ = os.Chdir(pwd) + }() + + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + return testutil.MockGlobalData(testcase.args, &stdout), nil + } + err = app.Run(testcase.args, nil) + + t.Log(stdout.String()) + + testutil.AssertErrorContains(t, err, testcase.wantError) + for _, s := range testcase.wantOutput { + testutil.AssertStringContains(t, stdout.String(), s) + } + + for _, files := range testcase.expectedFiles { + fpath := filepath.Join(rootdir, filepath.Join(files...)) + _, err = os.Stat(fpath) + if err != nil { + t.Fatalf("the specified file is not in the expected location: %v", err) + } + } + }) + } +} diff --git a/pkg/commands/compute/publish.go b/pkg/commands/compute/publish.go new file mode 100644 index 000000000..88e6710c6 --- /dev/null +++ b/pkg/commands/compute/publish.go @@ -0,0 +1,207 @@ +package compute + +import ( + "fmt" + "io" + "os" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// PublishCommand produces and deploys an artifact from files on the local disk. +type PublishCommand struct { + argparser.Base + build *BuildCommand + deploy *DeployCommand + + // Build fields + dir argparser.OptionalString + includeSrc argparser.OptionalBool + lang argparser.OptionalString + metadataDisable argparser.OptionalBool + metadataFilterEnvVars argparser.OptionalString + metadataShow argparser.OptionalBool + packageName argparser.OptionalString + timeout argparser.OptionalInt + + // Deploy fields + comment argparser.OptionalString + domain argparser.OptionalString + env argparser.OptionalString + pkg argparser.OptionalString + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion + statusCheckCode int + statusCheckOff bool + statusCheckPath string + statusCheckTimeout int + + // Publish private fields + projectDir string +} + +// NewPublishCommand returns a usable command registered under the parent. +func NewPublishCommand(parent argparser.Registerer, g *global.Data, build *BuildCommand, deploy *DeployCommand) *PublishCommand { + var c PublishCommand + c.Globals = g + c.build = build + c.deploy = deploy + c.CmdClause = parent.Command("publish", "Build and deploy a Compute package to a Fastly service") + + c.CmdClause.Flag("comment", "Human-readable comment").Action(c.comment.Set).StringVar(&c.comment.Value) + c.CmdClause.Flag("dir", "Project directory to build (default: current directory)").Short('C').Action(c.dir.Set).StringVar(&c.dir.Value) + c.CmdClause.Flag("domain", "The name of the domain associated to the package").Action(c.domain.Set).StringVar(&c.domain.Value) + c.CmdClause.Flag("env", "The manifest environment config to use (e.g. 'stage' will attempt to read 'fastly.stage.toml')").Action(c.env.Set).StringVar(&c.env.Value) + c.CmdClause.Flag("include-source", "Include source code in built package").Action(c.includeSrc.Set).BoolVar(&c.includeSrc.Value) + c.CmdClause.Flag("language", "Language type").Action(c.lang.Set).StringVar(&c.lang.Value) + c.CmdClause.Flag("metadata-disable", "Disable Wasm binary metadata annotations").Action(c.metadataDisable.Set).BoolVar(&c.metadataDisable.Value) + c.CmdClause.Flag("metadata-filter-envvars", "Redact specified environment variables from [scripts.env_vars] using comma-separated list").Action(c.metadataFilterEnvVars.Set).StringVar(&c.metadataFilterEnvVars.Value) + c.CmdClause.Flag("metadata-show", "Inspect the Wasm binary metadata").Action(c.metadataShow.Set).BoolVar(&c.metadataShow.Value) + c.CmdClause.Flag("package", "Path to a package tar.gz").Short('p').Action(c.pkg.Set).StringVar(&c.pkg.Value) + c.CmdClause.Flag("package-name", "Package name").Action(c.packageName.Set).StringVar(&c.packageName.Value) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &c.Globals.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + c.CmdClause.Flag("status-check-code", "Set the expected status response for the service availability check to the root path").IntVar(&c.statusCheckCode) + c.CmdClause.Flag("status-check-off", "Disable the service availability check").BoolVar(&c.statusCheckOff) + c.CmdClause.Flag("status-check-path", "Specify the URL path for the service availability check").Default("/").StringVar(&c.statusCheckPath) + c.CmdClause.Flag("status-check-timeout", "Set a timeout (in seconds) for the service availability check").Default("120").IntVar(&c.statusCheckTimeout) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Action: c.serviceVersion.Set, + }) + c.CmdClause.Flag("timeout", "Timeout, in seconds, for the build compilation step").Action(c.timeout.Set).IntVar(&c.timeout.Value) + + return &c +} + +// Exec implements the command interface. +// +// NOTE: unlike other non-aggregate commands that initialize a new +// text.Progress type for displaying progress information to the user, we don't +// use that in this command because the nested commands overlap the output in +// non-deterministic ways. It's best to leave those nested commands to handle +// the progress indicator. +func (c *PublishCommand) Exec(in io.Reader, out io.Writer) (err error) { + wd, err := os.Getwd() + if err != nil { + return fmt.Errorf("failed to get current working directory: %w", err) + } + defer func() { + _ = os.Chdir(wd) + }() + + c.projectDir, err = ChangeProjectDirectory(c.dir.Value) + if err != nil { + return err + } + if c.projectDir != "" { + if c.Globals.Verbose() { + text.Info(out, ProjectDirMsg, c.projectDir) + } + } + + err = c.Build(in, out) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + text.Break(out) + + err = c.Deploy(in, out) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + return nil +} + +// Build constructs and executes the build logic. +func (c *PublishCommand) Build(in io.Reader, out io.Writer) error { + // Reset the fields on the BuildCommand based on PublishCommand values. + if c.dir.WasSet { + c.build.Flags.Dir = c.dir.Value + } + if c.env.WasSet { + c.build.Flags.Env = c.env.Value + } + if c.includeSrc.WasSet { + c.build.Flags.IncludeSrc = c.includeSrc.Value + } + if c.lang.WasSet { + c.build.Flags.Lang = c.lang.Value + } + if c.packageName.WasSet { + c.build.Flags.PackageName = c.packageName.Value + } + if c.timeout.WasSet { + c.build.Flags.Timeout = c.timeout.Value + } + if c.metadataDisable.WasSet { + c.build.MetadataDisable = c.metadataDisable.Value + } + if c.metadataFilterEnvVars.WasSet { + c.build.MetadataFilterEnvVars = c.metadataFilterEnvVars.Value + } + if c.metadataShow.WasSet { + c.build.MetadataShow = c.metadataShow.Value + } + if c.projectDir != "" { + c.build.SkipChangeDir = true // we've already changed directory + } + return c.build.Exec(in, out) +} + +// Deploy constructs and executes the deploy logic. +func (c *PublishCommand) Deploy(in io.Reader, out io.Writer) error { + // Reset the fields on the DeployCommand based on PublishCommand values. + if c.dir.WasSet { + c.deploy.Dir = c.dir.Value + } + if c.pkg.WasSet { + c.deploy.PackagePath = c.pkg.Value + } + if c.serviceName.WasSet { + c.deploy.ServiceName = c.serviceName // deploy's field is a argparser.OptionalServiceNameID + } + if c.serviceVersion.WasSet { + c.deploy.ServiceVersion = c.serviceVersion // deploy's field is a argparser.OptionalServiceVersion + } + if c.domain.WasSet { + c.deploy.Domain = c.domain.Value + } + if c.env.WasSet { + c.deploy.Env = c.env.Value + } + if c.comment.WasSet { + c.deploy.Comment = c.comment + } + if c.statusCheckCode > 0 { + c.deploy.StatusCheckCode = c.statusCheckCode + } + if c.statusCheckOff { + c.deploy.StatusCheckOff = c.statusCheckOff + } + if c.statusCheckTimeout > 0 { + c.deploy.StatusCheckTimeout = c.statusCheckTimeout + } + c.deploy.StatusCheckPath = c.statusCheckPath + if c.projectDir != "" { + c.build.SkipChangeDir = true // we've already changed directory + } + return c.deploy.Exec(in, out) +} diff --git a/pkg/commands/compute/root.go b/pkg/commands/compute/root.go new file mode 100644 index 000000000..27793d92d --- /dev/null +++ b/pkg/commands/compute/root.go @@ -0,0 +1,31 @@ +package compute + +import ( + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" +) + +// RootCommand is the parent command for all subcommands in this package. +// It should be installed under the primary root command. +type RootCommand struct { + argparser.Base + // no flags +} + +// CommandName is the string to be used to invoke this command. +const CommandName = "compute" + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent argparser.Registerer, g *global.Data) *RootCommand { + var c RootCommand + c.Globals = g + c.CmdClause = parent.Command(CommandName, "Manage Compute packages") + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { + panic("unreachable") +} diff --git a/pkg/commands/compute/secrets.go b/pkg/commands/compute/secrets.go new file mode 100644 index 000000000..50458d659 --- /dev/null +++ b/pkg/commands/compute/secrets.go @@ -0,0 +1,204 @@ +package compute + +import ( + "bytes" + "regexp" + "strings" +) + +// StaticSecretEnvVars is a static list of env vars containing secrets. +// +// NOTE: Env Vars pulled from https://github.com/Puliczek/awesome-list-of-secrets-in-environment-variables +// +// The reason for not listing more environment variables is because we have a +// generalised pattern `SecretGeneralisedEnvVarPattern` that catches the +// majority of formats used. +var StaticSecretEnvVars = []string{ + "AZURE_CLIENT_ID", + "CI_JOB_JWT", + "CI_JOB_JWT_V2", + "FACEBOOK_APP_ID", + "MSI_ENDPOINT", + "OKTA_AUTHN_GROUPID", + "OKTA_OAUTH2_CLIENTID", +} + +// SecretGeneralisedEnvVarPattern attempts to capture a secret assigned in an environment +// variable where the key follows a common pattern. +// +// Example: +// https://regex101.com/r/mf9Ymb/1 +var SecretGeneralisedEnvVarPattern = regexp.MustCompile(`(?i)\b[^\s]+_(?:API|CLIENTSECRET|CREDENTIALS|KEY|PASSWORD|SECRET|TOKEN)(?:[^=]+)?=(?:\s+)?"?([^\s"]+)`) // #nosec G101 (CWE-798) + +// AWSIDPattern is the pattern for an AWS ID. +var AWSIDPattern = regexp.MustCompile(`\b((?:AKIA|ABIA|ACCA|ASIA)[0-9A-Z]{16})\b`) + +// AWSSecretPattern is the pattern for an AWS Secret. +var AWSSecretPattern = regexp.MustCompile(`[^A-Za-z0-9+\/]{0,1}([A-Za-z0-9+\/]{40})[^A-Za-z0-9+\/]{0,1}`) + +// GitHubOAuthTokenPattern is the pattern for a GitHub OAuth token. +var GitHubOAuthTokenPattern = regexp.MustCompile(`\b((?:ghp|gho|ghu|ghs|ghr|github_pat)_[a-zA-Z0-9_]{36,255})\b`) + +// GitHubOldOAuthTokenPattern is the pattern for an older GitHub OAuth token format. +var GitHubOldOAuthTokenPattern = regexp.MustCompile(`(?i)(?:github|gh|pat|token)[^\.].{0,40}[ =:'"]+([a-f0-9]{40})\b`) + +// GitHubOAuth2ClientIDPattern is the pattern for a GitHub OAuth2 ClientID. +var GitHubOAuth2ClientIDPattern = regexp.MustCompile(`(?i)(?:github)(?:.|[\n\r]){0,40}\b([a-f0-9]{20})\b`) + +// GitHubOAuth2ClientSecretPattern is the pattern for a GitHub OAuth2 ClientID. +var GitHubOAuth2ClientSecretPattern = regexp.MustCompile(`(?i)(?:github)(?:.|[\n\r]){0,40}\b([a-f0-9]{40})\b`) + +// GitHubAppIDPattern is the pattern for a GitHub App ID. +var GitHubAppIDPattern = regexp.MustCompile(`(?i)(?:github)(?:.|[\n\r]){0,40}\b([0-9]{6})\b`) + +// GitHubAppKeyPattern is the pattern for a GitHub App Key. +var GitHubAppKeyPattern = regexp.MustCompile(`(?i)(?:github)(?:.|[\n\r]){0,40}(-----BEGIN RSA PRIVATE KEY-----\s[A-Za-z0-9+\/\s]*\s-----END RSA PRIVATE KEY-----)`) + +// SecretPatterns is a collection of secret identifying patterns. +// +// NOTE: Patterns pulled from https://github.com/trufflesecurity/trufflehog +var SecretPatterns = []*regexp.Regexp{ + AWSIDPattern, + AWSSecretPattern, + GitHubOAuthTokenPattern, + GitHubOldOAuthTokenPattern, + GitHubOAuth2ClientIDPattern, + GitHubOAuth2ClientSecretPattern, + GitHubAppIDPattern, + GitHubAppKeyPattern, +} + +// ExtendStaticSecretEnvVars mutates `StaticSecretEnvVars` to include user +// specified environment variables. The `filter` argument is comma-separated. +func ExtendStaticSecretEnvVars(filter string) { + customFilters := strings.Split(filter, ",") + for _, v := range customFilters { + if v == "" { + continue + } + var found bool + for _, f := range StaticSecretEnvVars { + if f == v { + found = true + break + } + } + if !found { + StaticSecretEnvVars = append(StaticSecretEnvVars, v) + } + } +} + +// FilterSecretsFromSlice returns the input slice modified such that any value +// assigned to an environment variable (identified as containing a secret) is +// redacted. Additionally, any 'value' identified as being a secret will also be +// redacted. +// +// NOTE: `data` is expected to contain "KEY=VALUE" formatted strings. +func FilterSecretsFromSlice(data []string) []string { + copyOfData := make([]string, len(data)) + copy(copyOfData, data) + + for i, keypair := range copyOfData { + k, v, found := strings.Cut(keypair, "=") + if !found { + return copyOfData + } + for _, f := range StaticSecretEnvVars { + if k == f { + copyOfData[i] = k + "=REDACTED" + break + } + } + if strings.Contains(copyOfData[i], "REDACTED") { + continue + } + for _, matches := range SecretGeneralisedEnvVarPattern.FindAllStringSubmatch(keypair, -1) { + if len(matches) == 2 { + o := matches[0] + n := strings.ReplaceAll(matches[0], matches[1], "REDACTED") + copyOfData[i] = strings.ReplaceAll(keypair, o, n) + } + } + if strings.Contains(copyOfData[i], "REDACTED") { + continue + } + for _, pattern := range SecretPatterns { + n := pattern.ReplaceAllString(v, "REDACTED") + copyOfData[i] = k + "=" + n + if n == "REDACTED" { + break + } + } + } + + return copyOfData +} + +// FilterSecretsFromString returns the input string modified such that any value +// assigned to an environment variable (identified as containing a secret) is +// redacted. Additionally, any 'value' identified as being a secret will also be +// redacted. +// +// Example: +// https://go.dev/play/p/jhCcC4SlsHA +// +// NOTE: The input data should be simple (i.e. not a complex json object). +// Otherwise the `SecretGeneralisedEnvVarPattern` will unlikely match all cases. +func FilterSecretsFromString(data string) string { + staticSecretEnvVarsPattern := regexp.MustCompile(`(?i)\b(?:` + strings.Join(StaticSecretEnvVars, "|") + `)(?:\s+)?=(?:\s+)?"?([^\s"]+)`) + for _, matches := range staticSecretEnvVarsPattern.FindAllStringSubmatch(data, -1) { + if len(matches) == 2 { + o := matches[0] + n := strings.ReplaceAll(matches[0], matches[1], "REDACTED") + data = strings.ReplaceAll(data, o, n) + } + } + for _, matches := range SecretGeneralisedEnvVarPattern.FindAllStringSubmatch(data, -1) { + if len(matches) == 2 { + o := matches[0] + n := strings.ReplaceAll(matches[0], matches[1], "REDACTED") + data = strings.ReplaceAll(data, o, n) + } + } + for _, pattern := range SecretPatterns { + data = pattern.ReplaceAllString(data, "REDACTED") + } + return data +} + +// FilterSecretsFromBytes returns the input string modified such that any value +// assigned to an environment variable (identified as containing a secret) is +// redacted. Additionally, any 'value' identified as being a secret will also be +// redacted. +// +// Example: +// https://go.dev/play/p/jhCcC4SlsHA +// +// NOTE: The input data should be simple (i.e. not a complex json object). +// Otherwise the `SecretGeneralisedEnvVarPattern` will unlikely match all cases. +func FilterSecretsFromBytes(data []byte) []byte { + copyOfData := make([]byte, len(data)) + copy(copyOfData, data) + + staticSecretEnvVarsPattern := regexp.MustCompile(`(?i)\b(?:` + strings.Join(StaticSecretEnvVars, "|") + `)(?:\s+)?=(?:\s+)?"?([^\s"]+)`) + for _, matches := range staticSecretEnvVarsPattern.FindAllSubmatch(copyOfData, -1) { + if len(matches) == 2 { + o := matches[0] + n := bytes.ReplaceAll(matches[0], matches[1], []byte("REDACTED")) + copyOfData = bytes.ReplaceAll(copyOfData, o, n) + } + } + for _, matches := range SecretGeneralisedEnvVarPattern.FindAllSubmatch(copyOfData, -1) { + if len(matches) == 2 { + o := matches[0] + n := bytes.ReplaceAll(matches[0], matches[1], []byte("REDACTED")) + copyOfData = bytes.ReplaceAll(copyOfData, o, n) + } + } + for _, pattern := range SecretPatterns { + copyOfData = pattern.ReplaceAll(copyOfData, []byte("REDACTED")) + } + + return copyOfData +} diff --git a/pkg/commands/compute/serve.go b/pkg/commands/compute/serve.go new file mode 100644 index 000000000..9c94ac049 --- /dev/null +++ b/pkg/commands/compute/serve.go @@ -0,0 +1,909 @@ +package compute + +import ( + "bytes" + "errors" + "fmt" + "io" + "io/fs" + "net" + "net/url" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "syscall" + "time" + + "github.com/bep/debounce" + "github.com/blang/semver" + "github.com/fatih/color" + "github.com/fsnotify/fsnotify" + ignore "github.com/sabhiram/go-gitignore" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/check" + fsterr "github.com/fastly/cli/pkg/errors" + fstexec "github.com/fastly/cli/pkg/exec" + "github.com/fastly/cli/pkg/filesystem" + "github.com/fastly/cli/pkg/github" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/manifest" + fstruntime "github.com/fastly/cli/pkg/runtime" + "github.com/fastly/cli/pkg/text" +) + +var viceroyError = fsterr.RemediationError{ + Inner: fmt.Errorf("a Viceroy version was not found"), + Remediation: fsterr.BugRemediation, +} + +// ServeCommand produces and runs an artifact from files on the local disk. +type ServeCommand struct { + argparser.Base + build *BuildCommand + + // Build fields + dir argparser.OptionalString + includeSrc argparser.OptionalBool + lang argparser.OptionalString + metadataDisable argparser.OptionalBool + metadataFilterEnvVars argparser.OptionalString + metadataShow argparser.OptionalBool + packageName argparser.OptionalString + timeout argparser.OptionalInt + + // Serve public fields (public for testing purposes) + ForceCheckViceroyLatest bool + ViceroyBinExtraArgs string + ViceroyBinPath string + ViceroyVersioner github.AssetVersioner + + // Serve private fields + addr string + debug bool + env argparser.OptionalString + file argparser.OptionalString + profileGuest bool + profileGuestDir argparser.OptionalString + projectDir string + skipBuild bool + watch bool + watchDir argparser.OptionalString +} + +// NewServeCommand returns a usable command registered under the parent. +func NewServeCommand(parent argparser.Registerer, g *global.Data, build *BuildCommand) *ServeCommand { + var c ServeCommand + c.build = build + c.Globals = g + c.ViceroyVersioner = g.Versioners.Viceroy + c.CmdClause = parent.Command("serve", "Build and run a Compute package locally") + + c.CmdClause.Flag("addr", "The IPv4 address and port to listen on").Default("127.0.0.1:7676").StringVar(&c.addr) + c.CmdClause.Flag("debug", "Run the server in Debug Adapter mode").Hidden().BoolVar(&c.debug) + c.CmdClause.Flag("dir", "Project directory to build (default: current directory)").Short('C').Action(c.dir.Set).StringVar(&c.dir.Value) + c.CmdClause.Flag("env", "The manifest environment config to use (e.g. 'stage' will attempt to read 'fastly.stage.toml')").Action(c.env.Set).StringVar(&c.env.Value) + c.CmdClause.Flag("file", "The Wasm file to run (causes build process to be skipped)").Action(c.file.Set).StringVar(&c.file.Value) + c.CmdClause.Flag("include-source", "Include source code in built package").Action(c.includeSrc.Set).BoolVar(&c.includeSrc.Value) + c.CmdClause.Flag("language", "Language type").Action(c.lang.Set).StringVar(&c.lang.Value) + c.CmdClause.Flag("metadata-disable", "Disable Wasm binary metadata annotations").Action(c.metadataDisable.Set).BoolVar(&c.metadataDisable.Value) + c.CmdClause.Flag("metadata-filter-envvars", "Redact specified environment variables from [scripts.env_vars] using comma-separated list").Action(c.metadataFilterEnvVars.Set).StringVar(&c.metadataFilterEnvVars.Value) + c.CmdClause.Flag("metadata-show", "Inspect the Wasm binary metadata").Action(c.metadataShow.Set).BoolVar(&c.metadataShow.Value) + c.CmdClause.Flag("package-name", "Package name").Action(c.packageName.Set).StringVar(&c.packageName.Value) + c.CmdClause.Flag("profile-guest", "Profile the Wasm guest under Viceroy (requires Viceroy 0.9.1 or higher). View profiles at https://profiler.firefox.com/.").BoolVar(&c.profileGuest) + c.CmdClause.Flag("profile-guest-dir", "The directory where the per-request profiles are saved to. Defaults to guest-profiles.").Action(c.profileGuestDir.Set).StringVar(&c.profileGuestDir.Value) + c.CmdClause.Flag("skip-build", "Skip the build step").BoolVar(&c.skipBuild) + c.CmdClause.Flag("timeout", "Timeout, in seconds, for the build compilation step").Action(c.timeout.Set).IntVar(&c.timeout.Value) + c.CmdClause.Flag("viceroy-args", "Additional arguments to pass to the Viceroy binary, separated by space").StringVar(&c.ViceroyBinExtraArgs) + c.CmdClause.Flag("viceroy-check", "Force the CLI to check for a newer version of the Viceroy binary").BoolVar(&c.ForceCheckViceroyLatest) + c.CmdClause.Flag("viceroy-path", "The path to a user installed version of the Viceroy binary").StringVar(&c.ViceroyBinPath) + c.CmdClause.Flag("watch", "Watch for file changes, then rebuild project and restart local server").BoolVar(&c.watch) + c.CmdClause.Flag("watch-dir", "The directory to watch files from (can be relative or absolute). Defaults to current directory.").Action(c.watchDir.Set).StringVar(&c.watchDir.Value) + + return &c +} + +// Exec implements the command interface. +func (c *ServeCommand) Exec(in io.Reader, out io.Writer) (err error) { + if c.skipBuild && c.watch { + return fsterr.ErrIncompatibleServeFlags + } + + if runtime.GOARCH == "386" { + return fsterr.RemediationError{ + Inner: errors.New("this command doesn't support the '386' architecture"), + Remediation: "Although the Fastly CLI supports '386', the `compute serve` command requires https://github.com/fastly/Viceroy which does not.", + } + } + + manifestFilename := EnvironmentManifest(c.env.Value) + if c.env.Value != "" { + if c.Globals.Verbose() { + text.Info(out, EnvManifestMsg, manifestFilename, manifest.Filename) + } + } + wd, err := os.Getwd() + if err != nil { + c.Globals.ErrLog.Add(err) + return fmt.Errorf("failed to get current working directory: %w", err) + } + defer func() { + _ = os.Chdir(wd) + }() + manifestPath := filepath.Join(wd, manifestFilename) + + c.projectDir, err = ChangeProjectDirectory(c.dir.Value) + if err != nil { + return err + } + if c.projectDir != "" { + if c.Globals.Verbose() { + text.Info(out, ProjectDirMsg, c.projectDir) + } + manifestPath = filepath.Join(c.projectDir, manifestFilename) + } + + wasmBinaryToRun := binWasmPath + if c.file.WasSet { + wasmBinaryToRun = c.file.Value + } + + // We skip the build if explicitly told to with --skip-build but also when the + // user sets --file to specify their own wasm binary to pass to Viceroy. This + // is typically for users who compile a Wasm binary using an unsupported + // programming language for the Fastly Compute platform. + if !c.skipBuild && !c.file.WasSet { + err = c.Build(in, out) + if err != nil { + return err + } + text.Break(out) + } + + c.setBackendsWithDefaultOverrideHostIfMissing(out) + + spinner, err := text.NewSpinner(out) + if err != nil { + return err + } + + // NOTE: We read again the manifest to catch a skip-build scenario. + // + // For example, a user runs `compute build` then `compute serve --skip-build`. + // In that scenario our in-memory manifest could be invalid as the user might + // have also called `compute serve --skip-build --env <...> --dir <...>`. + // + // If the user doesn't set --skip-build then `compute serve` will call + // `compute build` and the logic there will update the manifest in-memory data + // with the relevant project directory and environment manifest content. + if c.skipBuild || c.file.WasSet { + err := c.Globals.Manifest.File.Read(manifestPath) + if err != nil { + return fmt.Errorf("failed to parse manifest '%s': %w", manifestPath, err) + } + c.ViceroyVersioner.SetRequestedVersion(c.Globals.Manifest.File.LocalServer.ViceroyVersion) + if c.Globals.Verbose() { + if c.skipBuild || c.file.WasSet { + text.Break(out) + } + text.Info(out, "Fastly manifest set to: %s\n\n", manifestPath) + } + } + + bin, err := c.GetViceroy(spinner, out, manifestPath) + if err != nil { + return err + } + + err = spinner.Start() + if err != nil { + return err + } + msg := "Running local server" + spinner.Message(msg + "...") + + spinner.StopMessage(msg) + err = spinner.Stop() + if err != nil { + return err + } + + if c.Globals.Verbose() { + text.Break(out) + } + + var restart bool + for { + err = local(localOpts{ + addr: c.addr, + bin: bin, + debug: c.debug, + errLog: c.Globals.ErrLog, + extraArgs: c.ViceroyBinExtraArgs, + manifestPath: manifestPath, + out: out, + profileGuest: c.profileGuest, + profileGuestDir: c.profileGuestDir, + restarted: restart, + verbose: c.Globals.Verbose(), + wasmBinPath: wasmBinaryToRun, + watch: c.watch, + watchDir: c.watchDir, + }) + if err != nil { + if err != fsterr.ErrViceroyRestart { + if err == fsterr.ErrSignalInterrupt || err == fsterr.ErrSignalKilled { + text.Info(out, "\nLocal server stopped") + return nil + } + return err + } + + // Before restarting Viceroy we should rebuild. + text.Break(out) + err = c.Build(in, out) + if err != nil { + // NOTE: build errors at this point are going to be user related, so we + // should display the error but keep watching the files so we can + // rebuild successfully once the user has fixed the issues. + fsterr.Deduce(err).Print(color.Error) + } + restart = true + } + } +} + +// Build constructs and executes the build logic. +func (c *ServeCommand) Build(in io.Reader, out io.Writer) error { + // Reset the fields on the BuildCommand based on ServeCommand values. + if c.dir.WasSet { + c.build.Flags.Dir = c.dir.Value + } + if c.env.WasSet { + c.build.Flags.Env = c.env.Value + } + if c.includeSrc.WasSet { + c.build.Flags.IncludeSrc = c.includeSrc.Value + } + if c.lang.WasSet { + c.build.Flags.Lang = c.lang.Value + } + if c.packageName.WasSet { + c.build.Flags.PackageName = c.packageName.Value + } + if c.timeout.WasSet { + c.build.Flags.Timeout = c.timeout.Value + } + if c.metadataDisable.WasSet { + c.build.MetadataDisable = c.metadataDisable.Value + } + if c.metadataFilterEnvVars.WasSet { + c.build.MetadataFilterEnvVars = c.metadataFilterEnvVars.Value + } + if c.metadataShow.WasSet { + c.build.MetadataShow = c.metadataShow.Value + } + if c.projectDir != "" { + c.build.SkipChangeDir = true // we've already changed directory + } + return c.build.Exec(in, out) +} + +// setBackendsWithDefaultOverrideHostIfMissing sets an override_host for any +// local_server.backends that is missing that property. The value will only be +// set if the URL defined uses a hostname (e.g. http://127.0.0.1/ won't) so we +// can set the override_host to match the hostname. +func (c *ServeCommand) setBackendsWithDefaultOverrideHostIfMissing(out io.Writer) { + for k, backend := range c.Globals.Manifest.File.LocalServer.Backends { + if backend.OverrideHost == "" { + if u, err := url.Parse(backend.URL); err == nil { + segs := strings.Split(u.Host, ":") // avoid parsing IP with port + if ip := net.ParseIP(segs[0]); ip == nil { + if c.Globals.Verbose() { + text.Info(out, "[local_server.backends.%s] (%s) is configured without an `override_host`. We will use %s as a default to help avoid any unexpected errors. See https://www.fastly.com/documentation/reference/compute/fastly-toml#local-server for more details.", k, backend.URL, u.Host) + } + backend.OverrideHost = u.Host + c.Globals.Manifest.File.LocalServer.Backends[k] = backend + } + } + } + } +} + +// GetViceroy returns the path to the installed binary. +// +// If Viceroy is installed we either update it or pin it to the version defined +// in the fastly.toml [viceroy.viceroy_version]. Otherwise, if not installed, we +// install it in the same directory as the application configuration data. +// +// In the case of a network failure we fallback to the latest installed version of the +// Viceroy binary as long as one is installed and has the correct permissions. +func (c *ServeCommand) GetViceroy(spinner text.Spinner, out io.Writer, manifestPath string) (bin string, err error) { + if c.ViceroyBinPath != "" { + if c.Globals.Verbose() { + text.Info(out, "Using user provided install of Viceroy via --viceroy-path flag: %s\n\n", c.ViceroyBinPath) + } + return filepath.Abs(c.ViceroyBinPath) + } + + // Allows a user to use a version of Viceroy that is installed in the $PATH. + if usePath := os.Getenv("FASTLY_VICEROY_USE_PATH"); checkViceroyEnvVar(usePath) { + path, err := exec.LookPath("viceroy") + if err != nil { + return "", fmt.Errorf("failed to lookup viceroy binary in user $PATH (user has set $FASTLY_VICEROY_USE_PATH): %w", err) + } + if c.Globals.Verbose() { + text.Info(out, "Using user provided install of Viceroy via $PATH lookup: %s\n\n", path) + } + return filepath.Abs(path) + } + + bin = filepath.Join(github.InstallDir, c.ViceroyVersioner.BinaryName()) + + // NOTE: When checking if Viceroy is installed we don't use + // exec.LookPath("viceroy") because PATH is unreliable across OS platforms, + // but also we actually install Viceroy in the same location as the + // application configuration, which means it wouldn't be found looking up by + // the PATH env var. We could pass the path for the application configuration + // into exec.LookPath() but it's simpler to just execute the binary. + // + // gosec flagged this: + // G204 (CWE-78): Subprocess launched with variable + // Disabling as the variables come from trusted sources. + /* #nosec */ + // nosemgrep + command := exec.Command(bin, "--version") + + var installedVersion string + + stdoutStderr, err := command.CombinedOutput() + if err != nil { + c.Globals.ErrLog.Add(err) + } else { + // Check the version output has the expected format: `viceroy 0.1.0` + installedVersion = strings.TrimSpace(string(stdoutStderr)) + segs := strings.Split(installedVersion, " ") + if len(segs) < 2 { + return bin, viceroyError + } + installedVersion = segs[1] + } + + // If the user hasn't explicitly set a Viceroy version, then we'll use + // whatever the latest version is. + versionToInstall := "latest" + if v := c.ViceroyVersioner.RequestedVersion(); v != "" { + versionToInstall = v + + if _, err := semver.Parse(versionToInstall); err != nil { + return bin, fsterr.RemediationError{ + Inner: fmt.Errorf("failed to parse configured version as a semver: %w", err), + Remediation: fmt.Sprintf("Ensure the %s `viceroy_version` value '%s' (under the [local_server] section) is a valid semver (https://semver.org/), e.g. `0.1.0`)", manifestPath, versionToInstall), + } + } + } + + err = c.InstallViceroy(installedVersion, versionToInstall, manifestPath, bin, spinner) + if err != nil { + c.Globals.ErrLog.Add(err) + return bin, err + } + + err = github.SetBinPerms(bin) + if err != nil { + c.Globals.ErrLog.Add(err) + return bin, err + } + return bin, nil +} + +// checkViceroyEnvVar indicates if the CLI should use a Viceroy binary exposed +// on the user's $PATH. +func checkViceroyEnvVar(value string) bool { + switch strings.ToUpper(value) { + case "1", "TRUE": + return true + } + return false +} + +// InstallViceroy downloads the binary from GitHub. +// +// The logic flow is as follows: +// +// 1. Check if version to install is "latest" +// 2. If so, check the latest release matches the installed version. +// 3. If not latest, check the installed version matches the expected version. +func (c *ServeCommand) InstallViceroy( + installedVersion, versionToInstall, manifestPath, bin string, + spinner text.Spinner, +) error { + var ( + err error + msg, tmpBin string + ) + + switch { + case installedVersion == "": // Viceroy not installed + if c.Globals.Verbose() { + text.Info(c.Globals.Output, "Viceroy is not already installed, so we will install the %s version.\n\n", versionToInstall) + } + err = spinner.Start() + if err != nil { + return err + } + msg = fmt.Sprintf("Fetching Viceroy release: %s", versionToInstall) + spinner.Message(msg + "...") + + if versionToInstall == "latest" { + tmpBin, err = c.ViceroyVersioner.DownloadLatest() + } else { + tmpBin, err = c.ViceroyVersioner.DownloadVersion(versionToInstall) + } + case versionToInstall != "latest": + if installedVersion == versionToInstall { + if c.Globals.Verbose() { + text.Info(c.Globals.Output, "Viceroy is already installed, and the installed version matches the required version (%s) in the %s file.\n\n", versionToInstall, manifestPath) + } + return nil + } + if c.Globals.Verbose() { + text.Info(c.Globals.Output, "Viceroy is already installed, but the installed version (%s) doesn't match the required version (%s) specified in the %s file.\n\n", installedVersion, versionToInstall, manifestPath) + } + + err = spinner.Start() + if err != nil { + return err + } + msg = fmt.Sprintf("Fetching Viceroy release: %s", versionToInstall) + spinner.Message(msg + "...") + + tmpBin, err = c.ViceroyVersioner.DownloadVersion(versionToInstall) + case versionToInstall == "latest": + // Viceroy is already installed, so we check if the installed version matches the latest. + // But we'll skip that check if the TTL for the Viceroy LastChecked hasn't expired. + + stale := check.Stale(c.Globals.Config.Viceroy.LastChecked, c.Globals.Config.Viceroy.TTL) + if !stale && !c.ForceCheckViceroyLatest { + if c.Globals.Verbose() { + text.Info(c.Globals.Output, "Viceroy is installed but the CLI config (`fastly config`) shows the TTL, checking for a newer version, hasn't expired. To force a refresh, re-run the command with the `--viceroy-check` flag.\n\n") + } + return nil + } + + // IMPORTANT: We declare separately so to shadow `err` from parent scope. + var latestVersion string + + // NOTE: We won't stop the user because although we can't request the latest + // version of the tool, the user may have a local version already installed. + err = spinner.Process("Checking latest Viceroy release", func(_ *text.SpinnerWrapper) error { + latestVersion, err = c.ViceroyVersioner.LatestVersion() + if err != nil { + return fsterr.RemediationError{ + Inner: fmt.Errorf("error fetching latest version: %w", err), + Remediation: fsterr.NetworkRemediation, + } + } + return nil + }) + if err != nil { + return nil // short-circuit the rest of this function + } + + viceroyConfig := c.Globals.Config.Viceroy + viceroyConfig.LatestVersion = latestVersion + viceroyConfig.LastChecked = time.Now().Format(time.RFC3339) + + // Before attempting to write the config data back to disk we need to + // ensure we reassign the modified struct which is a copy (not reference). + c.Globals.Config.Viceroy = viceroyConfig + + err = c.Globals.Config.Write(c.Globals.ConfigPath) + if err != nil { + return err + } + + if c.Globals.Verbose() { + text.Info(c.Globals.Output, "\nThe CLI config (`fastly config`) has been updated with the latest Viceroy version: %s\n\n", latestVersion) + } + + if installedVersion != "" && installedVersion == latestVersion { + return nil + } + + err = spinner.Start() + if err != nil { + return err + } + msg = fmt.Sprintf("Fetching Viceroy release: %s", versionToInstall) + spinner.Message(msg + "...") + + tmpBin, err = c.ViceroyVersioner.DownloadLatest() + } + + // NOTE: The above `switch` needs to shadow the function-level `err` variable. + if err != nil { + err = fmt.Errorf("error downloading Viceroy release: %w", err) + spinner.StopFailMessage(msg) + spinErr := spinner.StopFail() + if spinErr != nil { + return fmt.Errorf(text.SpinnerErrWrapper, spinErr, err) + } + return err + } + defer os.RemoveAll(tmpBin) + + if err := os.Rename(tmpBin, bin); err != nil { + err = fmt.Errorf("failed to rename/move file: %w", err) + if copyErr := filesystem.CopyFile(tmpBin, bin); copyErr != nil { + err = fmt.Errorf("failed to copy file: %w (original error: %w)", copyErr, err) + spinner.StopFailMessage(msg) + spinErr := spinner.StopFail() + if spinErr != nil { + return fmt.Errorf(text.SpinnerErrWrapper, spinErr, err) + } + return err + } + } + + spinner.StopMessage(msg) + return spinner.Stop() +} + +// localOpts represents the inputs for `local()`. +type localOpts struct { + addr string + bin string + debug bool + errLog fsterr.LogInterface + extraArgs string + manifestPath string + out io.Writer + profileGuest bool + profileGuestDir argparser.OptionalString + restarted bool + verbose bool + wasmBinPath string + watch bool + watchDir argparser.OptionalString +} + +// local spawns a subprocess that runs the compiled binary. +func local(opts localOpts) error { + // NOTE: Viceroy no longer displays errors unless in verbose mode. + // This can cause confusion for customers: https://github.com/fastly/cli/issues/913 + // So regardless of CLI --verbose flag we'll always set verbose for Viceroy. + args := []string{"-v", "-C", opts.manifestPath, "--addr", opts.addr, opts.wasmBinPath} + + if opts.debug { + args = append(args, "--debug") + } + + if opts.profileGuest { + directory := "guest-profiles" + if opts.profileGuestDir.WasSet { + directory = opts.profileGuestDir.Value + } + args = append(args, "--profile=guest,"+directory) + if opts.verbose { + text.Info(opts.out, "Saving per-request profiles to %s.", directory) + } + } + + if opts.extraArgs != "" { + extraArgs := strings.Split(opts.extraArgs, " ") + args = append(args, extraArgs...) + } + + if opts.verbose { + if opts.restarted { + text.Break(opts.out) + } + text.Output(opts.out, "%s: %s", text.BoldYellow("Manifest"), opts.manifestPath) + text.Output(opts.out, "%s: %s", text.BoldYellow("Wasm binary"), opts.wasmBinPath) + text.Output(opts.out, "%s: %s", text.BoldYellow("Viceroy command"), strings.Join(args, " ")) + text.Output(opts.out, "%s: %s", text.BoldYellow("Viceroy binary"), opts.bin) + + // gosec flagged this: + // G204 (CWE-78): Subprocess launched with function call as argument or cmd arguments + // Disabling as we trust the source of the variable. + // #nosec + // nosemgrep: go.lang.security.audit.dangerous-exec-command.dangerous-exec-command + c := exec.Command(opts.bin, "--version") + if output, err := c.Output(); err == nil { + text.Output(opts.out, "%s: %s", text.BoldYellow("Viceroy version"), string(output)) + } + text.Info(opts.out, "Listening on http://%s", opts.addr) + if opts.watch { + text.Break(opts.out) + } + } + + s := &fstexec.Streaming{ + Args: args, + Command: opts.bin, + Env: os.Environ(), + ForceOutput: true, + Output: opts.out, + SignalCh: make(chan os.Signal, 1), + } + s.MonitorSignals() + + failure := make(chan error) + restart := make(chan bool) + if opts.watch { + root := "." + if opts.watchDir.WasSet { + root = opts.watchDir.Value + } + + if opts.verbose { + text.Info(opts.out, "Watching files for changes (using --watch-dir=%s). To ignore certain files, define patterns within a .fastlyignore config file (uses .fastlyignore from --watch-dir).\n\n", root) + } + + gi := ignoreFiles(opts.watchDir) + go watchFiles(root, gi, opts.verbose, s, opts.out, restart, failure) + } + + // NOTE: The viceroy executable can be stopped by one of three mechanisms. + // + // 1. File modification + // 2. Explicit signal (SIGINT, SIGTERM etc). + // 3. Irrecoverable error (i.e. error watching files). + // + // In the case of a signal (e.g. user presses Ctrl-c) the listener logic + // inside of (*fstexec.Streaming).MonitorSignals() will call + // (*fstexec.Streaming).Signal(signal os.Signal) to kill the process. + // + // In the case of a file modification the viceroy executable needs to first + // be killed (handled by the watchFiles() function) and then we can stop the + // signal listeners (handled below by sending a message to argparser.SignalCh). + // + // If we don't tell the signal listening channel to close, then the restart + // of the viceroy executable will cause the user to end up with N number of + // listeners. This will result in a "os: process already finished" error when + // we do finally come to stop the `serve` command (e.g. user presses Ctrl-c). + // How big an issue this is depends on how many file modifications a user + // makes, because having lots of signal listeners could exhaust resources. + // + // When there is an error setting up the watching of files, if we error we + // need to signal the error using a channel as watching files happens + // asynchronously in a goroutine. We also need to be able to signal the + // viceroy process to be killed, and we do that using `s.Signal(os.Kill)` from + // within the relevant error handling blocks in `watchFiles`, where upon the + // below `select` statement will pull the error message from the `failure` + // channel and return it to the user. If we fail to kill the Viceroy process + // then we still want to pull an error from the `failure` channel and so we + // have a separate `select` statement to check for any initial errors prior to + // the Viceroy executable starting and an error occurring in `watchFiles`. + select { + case asyncErr := <-failure: + s.SignalCh <- syscall.SIGTERM + return asyncErr + case <-time.After(1 * time.Second): + // no-op: allow logic to flow to starting up Viceroy executable. + } + + if err := s.Exec(); err != nil { + errPrefix := "signal: " + errKilled := "killed" + if fstruntime.Windows { + errPrefix = "exit status" + errKilled = errPrefix + " 1" + } + + if !strings.Contains(err.Error(), errPrefix) { + opts.errLog.Add(err) + } + e := strings.TrimSpace(err.Error()) + if strings.Contains(e, "interrupt") { + return fsterr.ErrSignalInterrupt + } + if strings.Contains(e, errKilled) { + select { + case asyncErr := <-failure: + s.SignalCh <- syscall.SIGTERM + return asyncErr + case <-restart: + s.SignalCh <- syscall.SIGTERM + return fsterr.ErrViceroyRestart + case <-time.After(1 * time.Second): + return fsterr.ErrSignalKilled + } + } + return err + } + + return nil +} + +// watchFiles watches the language source directory and restarts the viceroy +// executable when changes are detected. +func watchFiles(root string, gi *ignore.GitIgnore, verbose bool, s *fstexec.Streaming, out io.Writer, restart chan<- bool, failure chan<- error) { + watcher, err := fsnotify.NewWatcher() + if err != nil { + signalErr := s.Signal(os.Kill) + if signalErr != nil { + failure <- fmt.Errorf("failed to stop Viceroy executable while trying to create a fsnotify.Watcher: %w: %w", signalErr, err) + return + } + failure <- fmt.Errorf("failed to create a fsnotify.Watcher: %w", err) + return + } + defer watcher.Close() + + done := make(chan bool) + debounced := debounce.New(1 * time.Second) + eventHandler := func(modifiedFile string, _ fsnotify.Op) { + // NOTE: We avoid describing the file operation (e.g. created, modified, + // deleted, renamed etc) rather than checking the fsnotify.Op iota/enum type + // because the output can be confusing depending on the application used to + // edit a file. + // + // For example, modifying a file in Vim might cause the file to be + // temporarily copied/renamed and this can cause the watcher to report an + // existing file has been 'created' or 'renamed' when from a user's + // perspective the file already exists and was only modified. + text.Break(out) + text.Output(out, "%s Restarting local server (%s)", text.BoldGreen("✓"), modifiedFile) + + // NOTE: We force closing the watcher by pushing true into a done channel. + // We do this because if we didn't, then we'd get an error after one + // restart of the viceroy executable: "os: process already finished". + // + // This error happens happens because the compute.watchFiles() function is + // run in a goroutine and so it will keep running with a copy of the + // fstexec.Streaming command instance that wraps a process which has + // already been terminated. + done <- true + + // NOTE: To be able to force both the current viceroy process signal listener + // to close, and to restart the viceroy executable, we need to kill the + // process and also send 'true' to a restart channel. + // + // If we only sent a message to the restart channel, but didn't terminate + // the process, then we'd end up in a deadlock because we wouldn't be able + // to take a message from the restart channel inside the local() function + // because we need to have the process terminate first in order for us to + // execute the flushing of channel messages. + // + // When we stop the signal listener it will internally try to kill the + // process and discover it has already been killed and return an error: + // `os: process already finished`. This is why we don't do error handling + // within (*fstexec.Streaming).MonitorSignalsAsync() as the process could + // well be killed already when a user is doing local development with the + // --watch flag. The obvious downside to this logic flow is that if the + // user is running `compute serve` just to validate the program once, then + // there might be an unhandled error when they press Ctrl-c to stop the + // serve command from blocking their terminal. That said, this is unlikely + // and is a low risk concern. + err := s.Signal(os.Kill) + if err != nil { + failure <- fmt.Errorf("failed to stop Viceroy executable while trying to restart the process: %w", err) + return + } + + restart <- true + } + + go func() { + for { + select { + case event, ok := <-watcher.Events: + if !ok { + return + } + debounced(func() { + eventHandler(event.Name, event.Op) + }) + case err, ok := <-watcher.Errors: + if !ok { + return + } + text.Output(out, "error event while watching files: %v", err) + } + } + }() + + var buf bytes.Buffer + + // Walk all directories and files starting from the project's root directory. + err = filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return fmt.Errorf("error configuring watching for file changes: %w", err) + } + // If there's no ignore file, we'll default to watching all directories + // within the specified top-level directory. + // + // NOTE: Watching a directory implies watching all files within the root of + // the directory. This means we don't need to call Add(path) for each file. + if gi == nil && entry.IsDir() { + watchFile(path, watcher, verbose, &buf) + } + if gi != nil && !entry.IsDir() && !gi.MatchesPath(path) { + // If there is an ignore file, we avoid watching directories and instead + // will only add files that don't match the exclusion patterns defined. + watchFile(path, watcher, verbose, &buf) + } + return nil + }) + if err != nil { + signalErr := s.Signal(os.Kill) + if signalErr != nil { + failure <- fmt.Errorf("failed to stop Viceroy executable while trying to walk directory tree for watching files: %w: %w", signalErr, err) + return + } + failure <- fmt.Errorf("failed to walk directory tree for watching files: %w", err) + return + } + + if verbose { + text.Output(out, "%s\n\n", text.BoldYellow("Watching...")) + fmt.Fprintln(out, buf.String()) // IMPORTANT: Avoid text.Output() as it fails to render with large buffer. + text.Break(out) + } + + <-done +} + +// ignoreFiles returns the specific ignore rules being respected. +// +// NOTE: We also ignore the .git directory. +func ignoreFiles(watchDir argparser.OptionalString) *ignore.GitIgnore { + var patterns []string + + root := "" + if watchDir.WasSet { + root = watchDir.Value + if !strings.HasPrefix(root, "/") { + root += "/" + } + } + + fastlyIgnore := root + ".fastlyignore" + + // NOTE: Using a loop to allow for future ignore files to be respected. + for _, file := range []string{fastlyIgnore} { + patterns = append(patterns, readIgnoreFile(file)...) + } + + patterns = append(patterns, ".git/") + + return ignore.CompileIgnoreLines(patterns...) +} + +// readIgnoreFile reads path and splits content into lines. +// +// NOTE: If there's an error reading the given path, then we'll return an empty +// string slice so that the caller can continue to function as expected. +func readIgnoreFile(path string) (lines []string) { + // gosec flagged this: + // G304 (CWE-22): Potential file inclusion via variable + // + // Disabling as the input is either provided by our own package or in the + // case of identifying the user's global git ignore we need to read it from + // their global git configuration. + /* #nosec */ + bs, err := os.ReadFile(path) + if err != nil { + return lines + } + return strings.Split(string(bs), "\n") +} + +func watchFile(path string, watcher *fsnotify.Watcher, verbose bool, out io.Writer) { + absolute, err := filepath.Abs(path) + if err != nil && verbose { + text.Warning(out, "Unable to convert '%s' to an absolute path", path) + return + } + + err = watcher.Add(absolute) + if err != nil { + text.Output(out, "%s %s", text.BoldRed("✗"), absolute) + } else if verbose { + text.Output(out, "%s", absolute) + } +} diff --git a/pkg/commands/compute/serve_test.go b/pkg/commands/compute/serve_test.go new file mode 100644 index 000000000..09a3aa644 --- /dev/null +++ b/pkg/commands/compute/serve_test.go @@ -0,0 +1,123 @@ +package compute_test + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/compute" + "github.com/fastly/cli/pkg/config" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/github" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" + "github.com/fastly/cli/pkg/text" +) + +// TestGetViceroy validates that Viceroy is installed to the appropriate +// directory. +// +// There isn't an executable binary that exists in the test environment, so we +// expect the spawning of a subprocess (to call ` --version`) to error +// and subsequently the `installViceroy()` function to be called. +// +// The `installViceroy()` function will then think it has downloaded the latest +// release as we have instructed the mock to provide that behaviour. +// +// Subsequently the `os.Rename()` will move the downloaded Viceroy binary, +// which is just a dummy file created by `testutil.NewEnv`, into the intended +// destination directory. +func TestGetViceroy(t *testing.T) { + wd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + + viceroyBinName := "foo" + installDirName := "install" + + rootdir := testutil.NewEnv(testutil.EnvOpts{ + T: t, + Dirs: []string{ + installDirName, + }, + Write: []testutil.FileIO{ + {Src: "...", Dst: viceroyBinName}, + + // NOTE: The reason for creating this file is that in serve.go when it tries + // to write in-memory data back to disk, although we don't need to validate + // the contents being written, we don't want the write to fail because no + // such file existed. + {Src: "", Dst: config.FileName}, + }, + }) + installDir := filepath.Join(rootdir, installDirName) + binPath := filepath.Join(rootdir, viceroyBinName) + configPath := filepath.Join(rootdir, config.FileName) + defer os.RemoveAll(rootdir) + + if err := os.Chdir(rootdir); err != nil { + t.Fatal(err) + } + defer func() { + _ = os.Chdir(wd) + }() + + github.InstallDir = installDir + + var out bytes.Buffer + + av := mock.AssetVersioner{ + AssetVersion: "1.2.3", + BinaryFilename: viceroyBinName, + DownloadOK: true, + DownloadedFile: binPath, + } + + var file config.File + + // NOTE: We purposefully provide a nonsensical path, which we expect to fail, + // but the function call should fallback to using the stubbed static config + // defined above. We also don't pass stdin, stdout arguments as that + // particular user flow isn't executed in this test case. + err = file.Read("example", strings.NewReader("yes"), &out, fsterr.MockLog{}, false) + if err != nil { + t.Fatal(err) + } + + spinner, err := text.NewSpinner(&out) + if err != nil { + t.Fatal(err) + } + manifestPath := "fastly.toml" + serveCommand := &compute.ServeCommand{ + Base: argparser.Base{ + Globals: &global.Data{ + Config: file, + ConfigPath: configPath, + ErrLog: fsterr.MockLog{}, + }, + }, + ForceCheckViceroyLatest: false, + ViceroyBinPath: "", + ViceroyVersioner: av, + } + _, err = serveCommand.GetViceroy(spinner, &out, manifestPath) + if err != nil { + t.Fatal(err) + } + + if !strings.Contains(out.String(), "Fetching Viceroy release: ") { + t.Fatalf("expected file to be downloaded successfully") + } + + movedPath := filepath.Join(installDir, viceroyBinName) + + if _, err := os.Stat(movedPath); err != nil { + t.Fatalf("binary was not moved to the install directory: %s", err) + } +} diff --git a/pkg/commands/compute/setup/backend.go b/pkg/commands/compute/setup/backend.go new file mode 100644 index 000000000..bcb5780e8 --- /dev/null +++ b/pkg/commands/compute/setup/backend.go @@ -0,0 +1,292 @@ +package setup + +import ( + "fmt" + "io" + "net" + "strconv" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/api" + "github.com/fastly/cli/pkg/commands/backend" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/text" +) + +// Backends represents the service state related to backends defined within the +// fastly.toml [setup] configuration. +// +// NOTE: It implements the setup.Interface interface. +type Backends struct { + // Public + APIClient api.Interface + AcceptDefaults bool + NonInteractive bool + Spinner text.Spinner + ServiceID string + ServiceVersion int + Setup map[string]*manifest.SetupBackend + Stdin io.Reader + Stdout io.Writer + + // Private + required []Backend +} + +// Backend represents the configuration parameters for creating a backend via +// the API client. +type Backend struct { + Address string + Name string + OverrideHost string + Port int + SSLCertHostname string + SSLSNIHostname string +} + +// Configure prompts the user for specific values related to the service resource. +func (b *Backends) Configure() error { + if b.Predefined() { + return b.checkPredefined() + } + return b.promptForBackend() +} + +// Create calls the relevant API to create the service resource(s). +func (b *Backends) Create() error { + if b.Spinner == nil { + return errors.RemediationError{ + Inner: fmt.Errorf("internal logic error: no spinner configured for setup.Backends"), + Remediation: errors.BugRemediation, + } + } + + for _, bk := range b.required { + // Avoids range-loop variable issue (i.e. var is reused across iterations). + bk := bk + + msg := fmt.Sprintf("Creating backend '%s' (host: %s, port: %d)", bk.Name, bk.Address, bk.Port) + + if !b.isOriginless() { + err := b.Spinner.Start() + if err != nil { + return err + } + b.Spinner.Message(msg + "...") + } + + opts := &fastly.CreateBackendInput{ + ServiceID: b.ServiceID, + ServiceVersion: b.ServiceVersion, + Name: &bk.Name, + Address: &bk.Address, + Port: &bk.Port, + } + + if bk.OverrideHost != "" { + opts.OverrideHost = &bk.OverrideHost + } + if bk.SSLCertHostname != "" { + opts.SSLCertHostname = &bk.SSLCertHostname + } + if bk.SSLSNIHostname != "" { + opts.SSLSNIHostname = &bk.SSLSNIHostname + } + + _, err := b.APIClient.CreateBackend(opts) + if err != nil { + if !b.isOriginless() { + err = fmt.Errorf("error creating backend: %w", err) + b.Spinner.StopFailMessage(msg) + spinErr := b.Spinner.StopFail() + if spinErr != nil { + return fmt.Errorf(text.SpinnerErrWrapper, spinErr, err) + } + } + return fmt.Errorf("error configuring the service: %w", err) + } + + if !b.isOriginless() { + b.Spinner.StopMessage(msg) + err = b.Spinner.Stop() + if err != nil { + return err + } + } + } + + return nil +} + +// Predefined indicates if the service resource has been specified within the +// fastly.toml file using a [setup] configuration block. +func (b *Backends) Predefined() bool { + return len(b.Setup) > 0 +} + +// isOriginless indicates if the required backend is originless. +func (b *Backends) isOriginless() bool { + return len(b.required) == 1 && b.required[0].Name == "originless" && b.required[0].Address == "127.0.0.1" +} + +// checkPredefined identifies specific backends that are required but missing +// from the user's service (based on the [setup.backends] configuration). +func (b *Backends) checkPredefined() error { + var i int + for name, settings := range b.Setup { + if !b.AcceptDefaults && !b.NonInteractive { + if i > 0 { + text.Break(b.Stdout) + } + i++ + text.Output(b.Stdout, "Configure a backend called '%s'", name) + if settings.Description != "" { + text.Output(b.Stdout, settings.Description) + } + text.Break(b.Stdout) + } + + var ( + addr string + err error + ) + + defaultAddress := "127.0.0.1" + if settings.Address != "" { + defaultAddress = settings.Address + } + + prompt := text.Prompt(fmt.Sprintf("Hostname or IP address: [%s] ", defaultAddress)) + + if !b.AcceptDefaults && !b.NonInteractive { + addr, err = text.Input(b.Stdout, prompt, b.Stdin, b.validateAddress) + if err != nil { + return fmt.Errorf("error reading prompt input: %w", err) + } + } + if addr == "" { + addr = defaultAddress + } + + port := int(443) + if settings.Port > 0 { + port = settings.Port + } + if !b.AcceptDefaults && !b.NonInteractive { + input, err := text.Input(b.Stdout, text.Prompt(fmt.Sprintf("Port: [%d] ", port)), b.Stdin) + if err != nil { + return fmt.Errorf("error reading prompt input: %w", err) + } + if input != "" { + if i, err := strconv.Atoi(input); err != nil { + text.Warning(b.Stdout, fmt.Sprintf("error converting prompt input, using default port number (%d)\n\n", port)) + } else { + port = i + } + } + } + + overrideHost, sslSNIHostname, sslCertHostname := backend.SetBackendHostDefaults(addr) + b.required = append(b.required, Backend{ + Address: addr, + Name: name, + OverrideHost: overrideHost, + Port: port, + SSLCertHostname: sslCertHostname, + SSLSNIHostname: sslSNIHostname, + }) + } + + return nil +} + +// promptForBackend issues a prompt requesting one or more Backends that will +// be created within the user's service. +func (b *Backends) promptForBackend() error { + if b.AcceptDefaults || b.NonInteractive { + b.required = append(b.required, b.createOriginlessBackend()) + return nil + } + + var i int + for { + if i > 0 { + text.Break(b.Stdout) + } + i++ + + addr, err := text.Input(b.Stdout, text.Prompt("Backend (hostname or IP address, or leave blank to stop adding backends): "), b.Stdin, b.validateAddress) + if err != nil { + return fmt.Errorf("error reading prompt input %w", err) + } + + // This block short-circuits the endless prompt loop + if addr == "" { + if len(b.required) == 0 { + b.required = append(b.required, b.createOriginlessBackend()) + } + return nil + } + + port := int(443) + input, err := text.Input(b.Stdout, text.Prompt(fmt.Sprintf("Backend port number: [%d] ", port)), b.Stdin) + if err != nil { + return fmt.Errorf("error reading prompt input: %w", err) + } + if input != "" { + if portnumber, err := strconv.Atoi(input); err != nil { + text.Warning(b.Stdout, fmt.Sprintf("error converting prompt input, using default port number (%d)\n\n", port)) + } else { + port = portnumber + } + } + + defaultName := fmt.Sprintf("backend_%d", i) + name, err := text.Input(b.Stdout, text.Prompt(fmt.Sprintf("Backend name: [%s] ", defaultName)), b.Stdin) + if err != nil { + return fmt.Errorf("error reading prompt input %w", err) + } + if name == "" { + name = defaultName + } + + overrideHost, sslSNIHostname, sslCertHostname := backend.SetBackendHostDefaults(addr) + b.required = append(b.required, Backend{ + Address: addr, + Name: name, + OverrideHost: overrideHost, + Port: port, + SSLCertHostname: sslCertHostname, + SSLSNIHostname: sslSNIHostname, + }) + } +} + +// createOriginlessBackend returns a Backend instance configured to the +// localhost settings expected of an 'originless' backend. +func (b *Backends) createOriginlessBackend() Backend { + var bk Backend + bk.Name = "originless" + bk.Address = "127.0.0.1" + bk.Port = int(80) + return bk +} + +// validateAddress checks the user entered address is a valid hostname or IP. +func (b *Backends) validateAddress(input string) error { + var isHost bool + if _, err := net.LookupHost(input); err == nil { + isHost = true + } + var isAddr bool + if _, err := net.LookupAddr(input); err == nil { + isAddr = true + } + isEmpty := input == "" + if !isEmpty && !isHost && !isAddr { + return fmt.Errorf(`must be a valid hostname, IPv4, or IPv6 address`) + } + return nil +} diff --git a/pkg/commands/compute/setup/config_store.go b/pkg/commands/compute/setup/config_store.go new file mode 100644 index 000000000..ee7c2dffd --- /dev/null +++ b/pkg/commands/compute/setup/config_store.go @@ -0,0 +1,229 @@ +package setup + +import ( + "fmt" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/api" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/text" +) + +// ConfigStores represents the service state related to config stores defined +// within the fastly.toml [setup] configuration. +// +// NOTE: It implements the setup.Interface interface. +type ConfigStores struct { + // Public + APIClient api.Interface + AcceptDefaults bool + NonInteractive bool + Spinner text.Spinner + ServiceID string + ServiceVersion int + Setup map[string]*manifest.SetupConfigStore + Stdin io.Reader + Stdout io.Writer + + // Private + required []ConfigStore +} + +// ConfigStore represents the configuration parameters for creating a config +// store via the API client. +type ConfigStore struct { + Name string + Items []ConfigStoreItem + LinkExistingStore bool + ExistingStoreID string +} + +// ConfigStoreItem represents the configuration parameters for creating config +// store items via the API client. +type ConfigStoreItem struct { + Key string + Value string +} + +// Configure prompts the user for specific values related to the service resource. +func (o *ConfigStores) Configure() error { + existingStores, err := o.APIClient.ListConfigStores(&fastly.ListConfigStoresInput{}) + if err != nil { + return err + } + + for name, settings := range o.Setup { + var ( + existingStoreID string + linkExistingStore bool + ) + + for _, store := range existingStores { + if store.Name == name { + if o.AcceptDefaults || o.NonInteractive { + linkExistingStore = true + existingStoreID = store.StoreID + } else { + text.Warning(o.Stdout, "\nA Config Store called '%s' already exists. If you use this store, then this implies that any keys defined in your setup configuration will either be newly created or will update an existing one. To avoid updating an existing key, then stop the command now and edit the setup configuration before re-running the deployment process\n\n", name) + prompt := text.Prompt("Use a different store name (or leave empty to use the existing store): ") + value, err := text.Input(o.Stdout, prompt, o.Stdin) + if err != nil { + return fmt.Errorf("error reading prompt input: %w", err) + } + if value == "" { + linkExistingStore = true + existingStoreID = store.StoreID + } else { + name = value + } + } + } + } + + if !o.AcceptDefaults && !o.NonInteractive { + text.Output(o.Stdout, "\nConfiguring config store '%s'", name) + if settings.Description != "" { + text.Output(o.Stdout, settings.Description) + } + } + + var items []ConfigStoreItem + + for key, item := range settings.Items { + dv := "example" + if item.Value != "" { + dv = item.Value + } + prompt := text.Prompt(fmt.Sprintf("Value: [%s] ", dv)) + + var ( + value string + err error + ) + + if !o.AcceptDefaults && !o.NonInteractive { + text.Output(o.Stdout, "\nCreate a config store key called '%s'", key) + if item.Description != "" { + text.Output(o.Stdout, item.Description) + } + text.Break(o.Stdout) + + value, err = text.Input(o.Stdout, prompt, o.Stdin) + if err != nil { + return fmt.Errorf("error reading prompt input: %w", err) + } + } + + if value == "" { + value = dv + } + + items = append(items, ConfigStoreItem{ + Key: key, + Value: value, + }) + } + + o.required = append(o.required, ConfigStore{ + Name: name, + Items: items, + LinkExistingStore: linkExistingStore, + ExistingStoreID: existingStoreID, + }) + } + + return nil +} + +// Create calls the relevant API to create the service resource(s). +func (o *ConfigStores) Create() error { + if o.Spinner == nil { + return errors.RemediationError{ + Inner: fmt.Errorf("internal logic error: no spinner configured for setup.ConfigStores"), + Remediation: errors.BugRemediation, + } + } + + for _, configStore := range o.required { + var ( + err error + cs *fastly.ConfigStore + ) + + if configStore.LinkExistingStore { + err = o.Spinner.Process(fmt.Sprintf("Retrieving existing Config Store '%s'", configStore.Name), func(_ *text.SpinnerWrapper) error { + cs, err = o.APIClient.GetConfigStore(&fastly.GetConfigStoreInput{ + StoreID: configStore.ExistingStoreID, + }) + if err != nil { + return fmt.Errorf("failed to get existing store '%s': %w", configStore.Name, err) + } + return nil + }) + if err != nil { + return err + } + } else { + err = o.Spinner.Process(fmt.Sprintf("Creating config store '%s'", configStore.Name), func(_ *text.SpinnerWrapper) error { + cs, err = o.APIClient.CreateConfigStore(&fastly.CreateConfigStoreInput{ + Name: configStore.Name, + }) + if err != nil { + return fmt.Errorf("error creating config store: %w", err) + } + return nil + }) + if err != nil { + return err + } + } + + if len(configStore.Items) > 0 { + for _, item := range configStore.Items { + err = o.Spinner.Process(fmt.Sprintf("Creating config store item '%s'", item.Key), func(_ *text.SpinnerWrapper) error { + _, err = o.APIClient.UpdateConfigStoreItem(&fastly.UpdateConfigStoreItemInput{ + Upsert: true, // Use upsert to avoid conflicts when reusing a starter kit. + StoreID: cs.StoreID, + Key: item.Key, + Value: item.Value, + }) + if err != nil { + return fmt.Errorf("error creating config store item: %w", err) + } + return nil + }) + if err != nil { + return err + } + } + } + + // IMPORTANT: We need to link the config store to the Compute Service. + err = o.Spinner.Process(fmt.Sprintf("Creating resource link between service and config store '%s'...", cs.Name), func(_ *text.SpinnerWrapper) error { + _, err = o.APIClient.CreateResource(&fastly.CreateResourceInput{ + ServiceID: o.ServiceID, + ServiceVersion: o.ServiceVersion, + Name: fastly.ToPointer(cs.Name), + ResourceID: fastly.ToPointer(cs.StoreID), + }) + if err != nil { + return fmt.Errorf("error creating resource link between the service '%s' and the config store '%s': %w", o.ServiceID, configStore.Name, err) + } + return nil + }) + if err != nil { + return err + } + } + + return nil +} + +// Predefined indicates if the service resource has been specified within the +// fastly.toml file using a [setup] configuration block. +func (o *ConfigStores) Predefined() bool { + return len(o.Setup) > 0 +} diff --git a/pkg/commands/compute/setup/doc.go b/pkg/commands/compute/setup/doc.go new file mode 100644 index 000000000..e506c44d4 --- /dev/null +++ b/pkg/commands/compute/setup/doc.go @@ -0,0 +1,3 @@ +// Package setup contains logic for managing the creation of resources that are +// defined within the fastly.toml manifest file. +package setup diff --git a/pkg/commands/compute/setup/domain.go b/pkg/commands/compute/setup/domain.go new file mode 100644 index 000000000..85a9543c3 --- /dev/null +++ b/pkg/commands/compute/setup/domain.go @@ -0,0 +1,224 @@ +package setup + +import ( + "fmt" + "io" + "net/http" + "regexp" + "strings" + + petname "github.com/dustinkirkland/golang-petname" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/api" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/text" +) + +const defaultTopLevelDomain = "edgecompute.app" + +var domainNameRegEx = regexp.MustCompile(`(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]`) + +// Domains represents the service state related to domains. +// +// NOTE: It implements the setup.Interface interface. +type Domains struct { + // Public + APIClient api.Interface + AcceptDefaults bool + NonInteractive bool + PackageDomain string + Spinner text.Spinner + RetryLimit int + ServiceID string + ServiceVersion int + Stdin io.Reader + Stdout io.Writer + Verbose bool + + // Private + available []*fastly.Domain + missing bool + required []Domain +} + +// Domain represents the configuration parameters for creating a domain via the +// API client. +type Domain struct { + Name string +} + +// Configure prompts the user for specific values related to the service resource. +// +// NOTE: If --domain flag is used we'll use that as the domain to create. +func (d *Domains) Configure() error { + // PackageDomain is the --domain flag value. + if d.PackageDomain != "" { + d.required = append(d.required, Domain{ + Name: d.PackageDomain, + }) + return nil + } + + defaultDomain := generateDomainName() + + var ( + domain string + err error + ) + if !d.AcceptDefaults && !d.NonInteractive { + text.Break(d.Stdout) + domain, err = text.Input(d.Stdout, text.Prompt(fmt.Sprintf("Domain: [%s] ", defaultDomain)), d.Stdin, d.validateDomain) + if err != nil { + return fmt.Errorf("error reading input %w", err) + } + text.Break(d.Stdout) + } + + if domain == "" { + domain = defaultDomain + } + d.required = append(d.required, Domain{ + Name: domain, + }) + + return nil +} + +// Create calls the relevant API to create the service resource(s). +func (d *Domains) Create() error { + if d.Spinner == nil { + return errors.RemediationError{ + Inner: fmt.Errorf("internal logic error: no spinner configured for setup.Domains"), + Remediation: errors.BugRemediation, + } + } + + for _, domain := range d.required { + if err := d.createDomain(domain.Name, 1); err != nil { + return err + } + } + + return nil +} + +// Missing indicates if there are missing resources that need to be created. +func (d *Domains) Missing() bool { + return d.missing || len(d.required) > 0 +} + +// Predefined indicates if the service resource has been specified within the +// fastly.toml file using a [setup] configuration block. +// +// NOTE: Domains are not configurable via the fastly.toml [setup] and so this +// becomes a no-op function that returned a canned response. +func (d *Domains) Predefined() bool { + return false +} + +// Validate checks if the service has the required resources. +// For a domain resource, we simply check there is at least one domain. +// +// NOTE: It should set an internal `missing` field (boolean) accordingly so that +// the Missing() method can report the state of the resource. +func (d *Domains) Validate() error { + available, err := d.APIClient.ListDomains(&fastly.ListDomainsInput{ + ServiceID: d.ServiceID, + ServiceVersion: d.ServiceVersion, + }) + if err != nil { + return fmt.Errorf("error fetching service domains: %w", err) + } + d.available = available + if len(d.available) == 0 { + d.missing = true + } + return nil +} + +// validateDomain checks the user entered domain is valid. +// +// NOTE: An empty value is allowed so that a default domain can be utilised. +func (d *Domains) validateDomain(input string) error { + if input == "" { + return nil + } + if !domainNameRegEx.MatchString(input) { + return fmt.Errorf("must be valid domain name") + } + return nil +} + +func (d *Domains) createDomain(name string, attempt int) error { + if !d.AcceptDefaults && !d.NonInteractive { + text.Break(d.Stdout) + } + + err := d.Spinner.Start() + if err != nil { + return err + } + msg := fmt.Sprintf("Creating domain '%s'", name) + d.Spinner.Message(msg + "...") + + _, err = d.APIClient.CreateDomain(&fastly.CreateDomainInput{ + ServiceID: d.ServiceID, + ServiceVersion: d.ServiceVersion, + Name: &name, + }) + if err != nil { + err = fmt.Errorf("error creating domain: %w", err) + + // We have to stop the ticker so we can now prompt the user. + d.Spinner.StopFailMessage(msg) + spinErr := d.Spinner.StopFail() + if spinErr != nil { + return fmt.Errorf(text.SpinnerErrWrapper, spinErr, err) + } + + if attempt > d.RetryLimit { + return fmt.Errorf("too many attempts") + } + + if e, ok := err.(*fastly.HTTPError); ok { + if e.StatusCode == http.StatusBadRequest { + for _, he := range e.Errors { + // NOTE: In case the domain is already used by another customer. + // We'll give the user one additional chance to correct the domain. + if strings.Contains(he.Detail, "by another customer") { + var domain string + defaultDomain := generateDomainName() + if !d.AcceptDefaults && !d.NonInteractive { + text.Break(d.Stdout) + domain, err = text.Input(d.Stdout, text.Prompt(fmt.Sprintf("Domain already taken, please choose another (attempt %d of %d): [%s] ", attempt, d.RetryLimit, defaultDomain)), d.Stdin, d.validateDomain) + if err != nil { + return fmt.Errorf("error reading input %w", err) + } + text.Break(d.Stdout) + } + if domain == "" { + domain = defaultDomain + } + return d.createDomain(domain, attempt+1) + } + } + } + } + + return err + } + + d.Spinner.StopMessage(msg) + return d.Spinner.Stop() +} + +func generateDomainName() string { + // IMPORTANT: go1.20 deprecates rand.Seed + // The global random number generator (RNG) is now automatically seeded. + // If not seeded, the same domain name is repeated on each run. + // If reverting CLI compilation to using 0 { + for _, item := range kvStore.Items { + err = o.Spinner.Process(fmt.Sprintf("Creating KV Store key '%s'...", item.Key), func(_ *text.SpinnerWrapper) error { + input := &fastly.InsertKVStoreKeyInput{ + StoreID: store.StoreID, + Key: item.Key, + } + if item.Body != nil { + input.Body = item.Body + } else { + input.Value = item.Value + } + err = o.APIClient.InsertKVStoreKey(input) + if err != nil { + return fmt.Errorf("error creating KV Store key: %w", err) + } + return nil + }) + if err != nil { + return err + } + } + } + + // IMPORTANT: We need to link the KV Store to the Compute Service. + err = o.Spinner.Process(fmt.Sprintf("Creating resource link between service and KV Store '%s'...", kvStore.Name), func(_ *text.SpinnerWrapper) error { + _, err = o.APIClient.CreateResource(&fastly.CreateResourceInput{ + ServiceID: o.ServiceID, + ServiceVersion: o.ServiceVersion, + Name: fastly.ToPointer(store.Name), + ResourceID: fastly.ToPointer(store.StoreID), + }) + if err != nil { + return fmt.Errorf("error creating resource link between the service '%s' and the KV Store '%s': %w", o.ServiceID, store.Name, err) + } + return nil + }) + if err != nil { + return err + } + } + + return nil +} + +// Predefined indicates if the service resource has been specified within the +// fastly.toml file using a [setup] configuration block. +func (o *KVStores) Predefined() bool { + return len(o.Setup) > 0 +} diff --git a/pkg/commands/compute/setup/loggers.go b/pkg/commands/compute/setup/loggers.go new file mode 100644 index 000000000..5a6135ff6 --- /dev/null +++ b/pkg/commands/compute/setup/loggers.go @@ -0,0 +1,50 @@ +package setup + +import ( + "io" + + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/text" +) + +// Loggers represents the service state related to log entries defined within +// the fastly.toml [setup] configuration. +// +// NOTE: It implements the setup.Interface interface. +type Loggers struct { + Setup map[string]*manifest.SetupLogger + Stdout io.Writer +} + +// Logger represents the configuration parameters for creating a dictionary +// via the API client. +type Logger struct { + Provider string +} + +// Configure prompts the user for specific values related to the service resource. +func (l *Loggers) Configure() error { + text.Info(l.Stdout, "The package code requires the following log endpoints to be created.\n\n") + + for name, settings := range l.Setup { + text.Output(l.Stdout, "%s %s", text.Bold("Name:"), name) + if settings.Provider != "" { + text.Output(l.Stdout, "%s %s", text.Bold("Provider:"), settings.Provider) + } + text.Break(l.Stdout) + } + + text.Description( + l.Stdout, + "Refer to the help documentation for each provider (if no provider shown, then select your own)", + "fastly logging create --help", + ) + + return nil +} + +// Predefined indicates if the service resource has been specified within the +// fastly.toml file using a [setup] configuration block. +func (l *Loggers) Predefined() bool { + return len(l.Setup) > 0 +} diff --git a/pkg/commands/compute/setup/secret_store.go b/pkg/commands/compute/setup/secret_store.go new file mode 100644 index 000000000..470e4cb7a --- /dev/null +++ b/pkg/commands/compute/setup/secret_store.go @@ -0,0 +1,240 @@ +package setup + +import ( + "errors" + "fmt" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/api" + fsterrors "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/text" +) + +// SecretStores represents the service state related to secret stores defined +// within the fastly.toml [setup] configuration. +// +// NOTE: It implements the setup.Interface interface. +type SecretStores struct { + // Public + APIClient api.Interface + AcceptDefaults bool + NonInteractive bool + Spinner text.Spinner + ServiceID string + ServiceVersion int + Setup map[string]*manifest.SetupSecretStore + Stdin io.Reader + Stdout io.Writer + + // Private + required []SecretStore +} + +// SecretStore represents the configuration parameters for creating a +// secret store via the API client. +type SecretStore struct { + Name string + Entries []SecretStoreEntry + LinkExistingStore bool + ExistingStoreID string +} + +// SecretStoreEntry represents the configuration parameters for creating +// secret store items via the API client. +type SecretStoreEntry struct { + Name string + Secret string +} + +// Predefined indicates if the service resource has been specified within the +// fastly.toml file using a [setup] configuration block. +func (s *SecretStores) Predefined() bool { + return len(s.Setup) > 0 +} + +// Configure prompts the user for specific values related to the service resource. +func (s *SecretStores) Configure() error { + var ( + cursor string + existingStores []fastly.SecretStore + ) + + for { + o, err := s.APIClient.ListSecretStores(&fastly.ListSecretStoresInput{ + Cursor: cursor, + }) + if err != nil { + return err + } + if o != nil { + existingStores = append(existingStores, o.Data...) + if o.Meta.NextCursor != "" { + cursor = o.Meta.NextCursor + continue + } + break + } + } + + for name, settings := range s.Setup { + var ( + existingStoreID string + linkExistingStore bool + ) + + for _, store := range existingStores { + if store.Name == name { + if s.AcceptDefaults || s.NonInteractive { + linkExistingStore = true + existingStoreID = store.StoreID + } else { + text.Warning(s.Stdout, "\nA Secret Store called '%s' already exists\n\n", name) + prompt := text.Prompt("Use a different store name (or leave empty to use the existing store): ") + value, err := text.Input(s.Stdout, prompt, s.Stdin) + if err != nil { + return fmt.Errorf("error reading prompt input: %w", err) + } + if value == "" { + linkExistingStore = true + existingStoreID = store.StoreID + } else { + name = value + } + } + } + } + + if !s.AcceptDefaults && !s.NonInteractive { + text.Output(s.Stdout, "\nConfiguring Secret Store '%s'", name) + if settings.Description != "" { + text.Output(s.Stdout, settings.Description) + } + } + + store := SecretStore{ + Name: name, + Entries: make([]SecretStoreEntry, 0, len(settings.Entries)), + LinkExistingStore: linkExistingStore, + ExistingStoreID: existingStoreID, + } + + for key, entry := range settings.Entries { + var ( + value string + err error + ) + + if !s.AcceptDefaults && !s.NonInteractive { + text.Output(s.Stdout, "\nCreate a Secret Store entry called '%s'", key) + if entry.Description != "" { + text.Output(s.Stdout, entry.Description) + } + text.Break(s.Stdout) + + prompt := text.Prompt("Value: ") + value, err = text.InputSecure(s.Stdout, prompt, s.Stdin) + if err != nil { + return fmt.Errorf("error reading prompt input: %w", err) + } + } + + if value == "" { + return errors.New("value cannot be blank") + } + + store.Entries = append(store.Entries, SecretStoreEntry{ + Name: key, + Secret: value, + }) + } + + s.required = append(s.required, store) + } + + return nil +} + +// Create calls the relevant API to create the service resource(s). +func (s *SecretStores) Create() error { + if s.Spinner == nil { + return fsterrors.RemediationError{ + Inner: fmt.Errorf("internal logic error: no spinner configured for setup.SecretStores"), + Remediation: fsterrors.BugRemediation, + } + } + + for _, secretStore := range s.required { + var ( + err error + store *fastly.SecretStore + ) + + if secretStore.LinkExistingStore { + err = s.Spinner.Process(fmt.Sprintf("Retrieving existing Secret Store '%s'", secretStore.Name), func(_ *text.SpinnerWrapper) error { + store, err = s.APIClient.GetSecretStore(&fastly.GetSecretStoreInput{ + StoreID: secretStore.ExistingStoreID, + }) + if err != nil { + return fmt.Errorf("failed to get existing store '%s': %w", secretStore.Name, err) + } + return nil + }) + if err != nil { + return err + } + } else { + err = s.Spinner.Process(fmt.Sprintf("Creating Secret Store '%s'", secretStore.Name), func(_ *text.SpinnerWrapper) error { + store, err = s.APIClient.CreateSecretStore(&fastly.CreateSecretStoreInput{ + Name: secretStore.Name, + }) + if err != nil { + return fmt.Errorf("error creating Secret Store %q: %w", secretStore.Name, err) + } + return nil + }) + if err != nil { + return err + } + } + + for _, entry := range secretStore.Entries { + err = s.Spinner.Process(fmt.Sprintf("Creating Secret Store entry '%s'...", entry.Name), func(_ *text.SpinnerWrapper) error { + _, err = s.APIClient.CreateSecret(&fastly.CreateSecretInput{ + StoreID: store.StoreID, + Name: entry.Name, + Secret: []byte(entry.Secret), + }) + if err != nil { + return fmt.Errorf("error creating Secret Store entry %q: %w", entry.Name, err) + } + return nil + }) + if err != nil { + return err + } + } + + err = s.Spinner.Process(fmt.Sprintf("Creating resource link between service and Secret Store '%s'...", store.Name), func(_ *text.SpinnerWrapper) error { + // We need to link the secret store to the C@E Service, otherwise the service + // will not have access to the store. + _, err = s.APIClient.CreateResource(&fastly.CreateResourceInput{ + ServiceID: s.ServiceID, + ServiceVersion: s.ServiceVersion, + Name: fastly.ToPointer(store.Name), + ResourceID: fastly.ToPointer(store.StoreID), + }) + if err != nil { + return fmt.Errorf("error creating resource link between the service %q and the Secret Store %q: %w", s.ServiceID, store.Name, err) + } + return nil + }) + if err != nil { + return err + } + } + + return nil +} diff --git a/pkg/commands/compute/testdata/build/go/go.mod b/pkg/commands/compute/testdata/build/go/go.mod new file mode 100644 index 000000000..6d8bbcf9f --- /dev/null +++ b/pkg/commands/compute/testdata/build/go/go.mod @@ -0,0 +1,5 @@ +module cli-go-sdk + +go 1.18 + +require github.com/fastly/compute-sdk-go v0.1.1 diff --git a/pkg/commands/compute/testdata/build/go/main.go b/pkg/commands/compute/testdata/build/go/main.go new file mode 100644 index 000000000..d8fa92921 --- /dev/null +++ b/pkg/commands/compute/testdata/build/go/main.go @@ -0,0 +1,7 @@ +package main + +import "fmt" + +func main() { + fmt.Println("hello") +} diff --git a/pkg/commands/compute/testdata/build/javascript/package.json b/pkg/commands/compute/testdata/build/javascript/package.json new file mode 100644 index 000000000..d24f11324 --- /dev/null +++ b/pkg/commands/compute/testdata/build/javascript/package.json @@ -0,0 +1,28 @@ +{ + "name": "compute-starter-kit-javascript-default", + "version": "0.1.0", + "main": "src/index.js", + "repository": { + "type": "git", + "url": "git+https://github.com/fastly/compute-starter-kit-js-proto.git" + }, + "author": "oss@fastly.com", + "license": "MIT", + "bugs": { + "url": "https://github.com/fastly/compute-starter-kit-js-proto/issues" + }, + "homepage": "https://www.fastly.com/documentation/solutions/starters/compute-starter-kit-javascript-default", + "devDependencies": { + "core-js": "^3.15.2", + "webpack": "^5.10.0", + "webpack-cli": "^4.2.0" + }, + "dependencies": { + "@fastly/js-compute": "^0.1.0" + }, + "scripts": { + "prebuild": "webpack", + "build": "js-compute-runtime --skip-pkg bin/index.js bin/main.wasm", + "deploy": "npm run build && fastly compute deploy" + } +} diff --git a/pkg/commands/compute/testdata/build/javascript/src/index.js b/pkg/commands/compute/testdata/build/javascript/src/index.js new file mode 100644 index 000000000..3f360e060 --- /dev/null +++ b/pkg/commands/compute/testdata/build/javascript/src/index.js @@ -0,0 +1,52 @@ +// The entry point for your application. +// +// Use this fetch event listener to define your main request handling logic. It could be +// used to route based on the request properties (such as method or path), send +// the request to a backend, make completely new requests, and/or generate +// synthetic responses. +addEventListener('fetch', async function handleRequest(event) { + + // NOTE: By default, console messages are sent to stdout (and stderr for `console.error`). + // To send them to a logging endpoint instead, use `console.setEndpoint: + // console.setEndpoint("my-logging-endpoint"); + + // Get the client request from the event + let req = event.request; + + // Make any desired changes to the client request. + req.headers.set("Host", "example.com"); + + // We can filter requests that have unexpected methods. + const VALID_METHODS = ["GET"]; + if (!VALID_METHODS.includes(req.method)) { + let response = new Response("This method is not allowed", { + status: 405 + }); + // Send the response back to the client. + event.respondWith(response); + return; + } + + let method = req.method; + let url = new URL(event.request.url); + + // If request is a `GET` to the `/` path, send a default response. + if (method == "GET" && url.pathname == "/") { + let headers = new Headers(); + headers.set('Content-Type', 'text/html; charset=utf-8'); + let response = new Response("\n", { + status: 200, + headers + }); + // Send the response back to the client. + event.respondWith(response); + return; + } + + // Catch all other requests and return a 404. + let response = new Response("The page you requested could not be found", { + status: 404 + }); + // Send the response back to the client. + event.respondWith(response); +}); diff --git a/pkg/commands/compute/testdata/build/javascript/webpack.config.js b/pkg/commands/compute/testdata/build/javascript/webpack.config.js new file mode 100644 index 000000000..35d287c59 --- /dev/null +++ b/pkg/commands/compute/testdata/build/javascript/webpack.config.js @@ -0,0 +1,20 @@ +const path = require("path"); +const webpack = require("webpack"); + +module.exports = { + entry: "./src/index.js", + optimization: { + minimize: true + }, + target: "webworker", + output: { + filename: "index.js", + path: path.resolve(__dirname, "bin"), + libraryTarget: "this", + }, + plugins: [ + new webpack.ProvidePlugin({ + URL: "core-js/web/url", + }), + ], +}; diff --git a/pkg/compute/testdata/build/Cargo.lock b/pkg/commands/compute/testdata/build/rust/Cargo.lock similarity index 99% rename from pkg/compute/testdata/build/Cargo.lock rename to pkg/commands/compute/testdata/build/rust/Cargo.lock index f5a022ca4..85737adeb 100644 --- a/pkg/compute/testdata/build/Cargo.lock +++ b/pkg/commands/compute/testdata/build/rust/Cargo.lock @@ -48,7 +48,7 @@ dependencies = [ ] [[package]] -name = "edge-compute-default-rust-template" +name = "fastly-compute-project" version = "0.1.0" dependencies = [ "fastly", diff --git a/pkg/compute/testdata/build/Cargo.toml b/pkg/commands/compute/testdata/build/rust/Cargo.toml similarity index 77% rename from pkg/compute/testdata/build/Cargo.toml rename to pkg/commands/compute/testdata/build/rust/Cargo.toml index e4f4cf92b..dfcc4aeb5 100644 --- a/pkg/compute/testdata/build/Cargo.toml +++ b/pkg/commands/compute/testdata/build/rust/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "edge-compute-default-rust-template" +name = "fastly-compute-project" version = "0.1.0" authors = ["phamann "] edition = "2018" diff --git a/pkg/compute/testdata/build/fastly.toml b/pkg/commands/compute/testdata/build/rust/fastly.toml similarity index 86% rename from pkg/compute/testdata/build/fastly.toml rename to pkg/commands/compute/testdata/build/rust/fastly.toml index 678966b0a..ce8192a16 100644 --- a/pkg/compute/testdata/build/fastly.toml +++ b/pkg/commands/compute/testdata/build/rust/fastly.toml @@ -1,4 +1,4 @@ -manifest_version = "0.1.0" +manifest_version = "0.2.0" name = "Default Rust template" description = "Default package template for Rust based edge compute projects." authors = ["phamann "] diff --git a/pkg/compute/testdata/build/src/main.rs b/pkg/commands/compute/testdata/build/rust/src/main.rs similarity index 95% rename from pkg/compute/testdata/build/src/main.rs rename to pkg/commands/compute/testdata/build/rust/src/main.rs index 623ab5fbe..bcf292956 100644 --- a/pkg/compute/testdata/build/src/main.rs +++ b/pkg/commands/compute/testdata/build/rust/src/main.rs @@ -1,3 +1,3 @@ fn main() { println!("Hello world!") -} +} \ No newline at end of file diff --git a/pkg/compute/testdata/deploy/pkg/package.tar.gz b/pkg/commands/compute/testdata/deploy/pkg/package.tar.gz similarity index 100% rename from pkg/compute/testdata/deploy/pkg/package.tar.gz rename to pkg/commands/compute/testdata/deploy/pkg/package.tar.gz diff --git a/pkg/compute/testdata/init/fastly-invalid-missing-version.toml b/pkg/commands/compute/testdata/init/fastly-invalid-missing-version.toml similarity index 100% rename from pkg/compute/testdata/init/fastly-invalid-missing-version.toml rename to pkg/commands/compute/testdata/init/fastly-invalid-missing-version.toml diff --git a/pkg/compute/testdata/init/fastly-invalid-section-version.toml b/pkg/commands/compute/testdata/init/fastly-invalid-section-version.toml similarity index 100% rename from pkg/compute/testdata/init/fastly-invalid-section-version.toml rename to pkg/commands/compute/testdata/init/fastly-invalid-section-version.toml diff --git a/pkg/compute/testdata/init/fastly-invalid-unrecognised.toml b/pkg/commands/compute/testdata/init/fastly-invalid-unrecognised.toml similarity index 100% rename from pkg/compute/testdata/init/fastly-invalid-unrecognised.toml rename to pkg/commands/compute/testdata/init/fastly-invalid-unrecognised.toml diff --git a/pkg/compute/testdata/init/fastly-invalid-version-exceeded.toml b/pkg/commands/compute/testdata/init/fastly-invalid-version-exceeded.toml similarity index 100% rename from pkg/compute/testdata/init/fastly-invalid-version-exceeded.toml rename to pkg/commands/compute/testdata/init/fastly-invalid-version-exceeded.toml diff --git a/pkg/commands/compute/testdata/init/fastly-missing-spec-url.toml b/pkg/commands/compute/testdata/init/fastly-missing-spec-url.toml new file mode 100644 index 000000000..99b315130 --- /dev/null +++ b/pkg/commands/compute/testdata/init/fastly-missing-spec-url.toml @@ -0,0 +1,5 @@ +manifest_version = 2 +name = "Default Rust template" +description = "Default package template for Rust based edge compute projects." +authors = ["phamann "] +language = "rust" diff --git a/pkg/commands/compute/testdata/init/fastly-valid-integer.toml b/pkg/commands/compute/testdata/init/fastly-valid-integer.toml new file mode 100644 index 000000000..99b315130 --- /dev/null +++ b/pkg/commands/compute/testdata/init/fastly-valid-integer.toml @@ -0,0 +1,5 @@ +manifest_version = 2 +name = "Default Rust template" +description = "Default package template for Rust based edge compute projects." +authors = ["phamann "] +language = "rust" diff --git a/pkg/commands/compute/testdata/init/fastly-valid-semver.toml b/pkg/commands/compute/testdata/init/fastly-valid-semver.toml new file mode 100644 index 000000000..527ba8c77 --- /dev/null +++ b/pkg/commands/compute/testdata/init/fastly-valid-semver.toml @@ -0,0 +1,5 @@ +manifest_version = "0.99.0" # minor and patch versions are ignored and zero major is bumped to latest +name = "Default Rust template" +description = "Default package template for Rust based edge compute projects." +authors = ["phamann "] +language = "rust" diff --git a/pkg/commands/compute/testdata/init/fastly-viceroy-update.toml b/pkg/commands/compute/testdata/init/fastly-viceroy-update.toml new file mode 100644 index 000000000..a55ced030 --- /dev/null +++ b/pkg/commands/compute/testdata/init/fastly-viceroy-update.toml @@ -0,0 +1,58 @@ +# This file describes a Fastly Compute package. To learn more visit: +# https://www.fastly.com/documentation/reference/compute/fastly-toml + +authors = ["phamann "] +description = "Default package template for Rust based edge compute projects." +language = "rust" +manifest_version = 2 +name = "Default Rust template" + +[local_server] + + [local_server.backends] + + [local_server.backends.backend_a] + url = "https://example.com/" + override_host = "otherexample.com" + + [local_server.backends.foo] + url = "https://foo.com/" + + [local_server.backends.bar] + url = "https://bar.com/" + + [local_server.dictionaries] + + [local_server.dictionaries.strings] + file = "strings.json" + format = "json" + + [local_server.dictionaries.toml] + format = "inline-toml" + + [local_server.dictionaries.toml.contents] + foo = "bar" + baz = """ +qux""" + + [local_server.kv_stores] + store_one = [{key = "first", data = "This is some data"}, {key = "second", path = "strings.json"}] + + [[local_server.kv_stores.store_two]] + key = "first" + data = "This is some data" + + [[local_server.kv_stores.store_two]] + key = "second" + file = "strings.json" + + [local_server.secret_stores] + store_one = [{key = "first", data = "This is some secret data"}, {key = "second", file = "/path/to/secret.json"}] + + [[local_server.secret_stores.store_two]] + key = "first" + data = "This is also some secret data" + + [[local_server.secret_stores.store_two]] + key = "second" + file = "/path/to/other/secret.json" diff --git a/pkg/commands/compute/testdata/main.wasm b/pkg/commands/compute/testdata/main.wasm new file mode 100644 index 000000000..d8fc92d02 Binary files /dev/null and b/pkg/commands/compute/testdata/main.wasm differ diff --git a/pkg/commands/compute/testdata/metadata/config.toml b/pkg/commands/compute/testdata/metadata/config.toml new file mode 100644 index 000000000..49326c75c --- /dev/null +++ b/pkg/commands/compute/testdata/metadata/config.toml @@ -0,0 +1,4 @@ +[wasm-metadata] +build_info = "disable" +machine_info = "disable" +package_info = "disable" diff --git a/pkg/commands/compute/testdata/pack/main.wasm b/pkg/commands/compute/testdata/pack/main.wasm new file mode 100644 index 000000000..e69de29bb diff --git a/pkg/commands/compute/update.go b/pkg/commands/compute/update.go new file mode 100644 index 000000000..52ce1f41e --- /dev/null +++ b/pkg/commands/compute/update.go @@ -0,0 +1,133 @@ +package compute + +import ( + "fmt" + "io" + "path/filepath" + + "github.com/kennygrant/sanitize" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/text" +) + +// UpdateCommand calls the Fastly API to update packages. +type UpdateCommand struct { + argparser.Base + path string + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion + autoClone argparser.OptionalAutoClone +} + +// NewUpdateCommand returns a usable command registered under the parent. +func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { + c := UpdateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("update", "Update a package on a Fastly Compute service version") + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.autoClone.Set, + Dst: &c.autoClone.Value, + }) + c.CmdClause.Flag("package", "Path to a package tar.gz").Short('p').StringVar(&c.path) + return &c +} + +// Exec invokes the application logic for the command. +func (c *UpdateCommand) Exec(_ io.Reader, out io.Writer) (err error) { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + AutoCloneFlag: c.autoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fsterr.ServiceVersion(serviceVersion), + }) + return err + } + + packagePath := c.path + if packagePath == "" { + projectName, source := c.Globals.Manifest.Name() + if source == manifest.SourceUndefined { + return fsterr.RemediationError{ + Inner: fmt.Errorf("failed to read project name: %w", fsterr.ErrReadingManifest), + Remediation: "Run `fastly compute build` to produce a Compute package, alternatively use the --package flag to reference a package outside of the current project.", + } + } + packagePath = filepath.Join("pkg", fmt.Sprintf("%s.tar.gz", sanitize.BaseName(projectName))) + } + + spinner, err := text.NewSpinner(out) + if err != nil { + return err + } + + defer func() { + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": serviceVersion.Number, + }) + } + }() + + serviceVersionNumber := fastly.ToValue(serviceVersion.Number) + + err = spinner.Process("Uploading package", func(_ *text.SpinnerWrapper) error { + _, err = c.Globals.APIClient.UpdatePackage(&fastly.UpdatePackageInput{ + ServiceID: serviceID, + ServiceVersion: serviceVersionNumber, + PackagePath: fastly.ToPointer(packagePath), + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": serviceVersionNumber, + }) + return fsterr.RemediationError{ + Inner: fmt.Errorf("error uploading package: %w", err), + Remediation: "Run `fastly compute build` to produce a Compute package, alternatively use the --package flag to reference a package outside of the current project.", + } + } + return nil + }) + if err != nil { + return err + } + + text.Success(out, "\nUpdated package (service %s, version %v)", serviceID, serviceVersionNumber) + return nil +} diff --git a/pkg/commands/compute/update_test.go b/pkg/commands/compute/update_test.go new file mode 100644 index 000000000..385eacc4c --- /dev/null +++ b/pkg/commands/compute/update_test.go @@ -0,0 +1,64 @@ +package compute_test + +import ( + "fmt" + "path/filepath" + "testing" + + root "github.com/fastly/cli/pkg/commands/compute" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" +) + +func TestUpdate(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "package API error", + Args: "-s 123 --version 1 --package pkg/package.tar.gz --autoclone", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + UpdatePackageFn: updatePackageError, + }, + Env: &testutil.EnvConfig{ + Opts: &testutil.EnvOpts{ + Copy: []testutil.FileIO{ + { + Src: filepath.Join("testdata", "deploy", "pkg", "package.tar.gz"), + Dst: filepath.Join("pkg", "package.tar.gz"), + }, + }, + }, + }, + WantError: fmt.Sprintf("error uploading package: %s", testutil.Err.Error()), + WantOutputs: []string{ + "Uploading package", + }, + }, + { + Name: "success", + Args: "-s 123 --version 2 --package pkg/package.tar.gz --autoclone", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + UpdatePackageFn: updatePackageOk, + }, + Env: &testutil.EnvConfig{ + Opts: &testutil.EnvOpts{ + Copy: []testutil.FileIO{ + { + Src: filepath.Join("testdata", "deploy", "pkg", "package.tar.gz"), + Dst: filepath.Join("pkg", "package.tar.gz"), + }, + }, + }, + }, + WantOutputs: []string{ + "Uploading package", + "Updated package (service 123, version 4)", + }, + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, "update"}, scenarios) +} diff --git a/pkg/commands/compute/validate.go b/pkg/commands/compute/validate.go new file mode 100644 index 000000000..0cc70530d --- /dev/null +++ b/pkg/commands/compute/validate.go @@ -0,0 +1,157 @@ +package compute + +import ( + "archive/tar" + "fmt" + "io" + "os" + "path/filepath" + + "github.com/kennygrant/sanitize" + "github.com/mholt/archiver/v3" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/text" +) + +// NewValidateCommand returns a usable command registered under the parent. +func NewValidateCommand(parent argparser.Registerer, g *global.Data) *ValidateCommand { + var c ValidateCommand + c.Globals = g + c.CmdClause = parent.Command("validate", "Validate a Compute package") + c.CmdClause.Flag("package", "Path to a package tar.gz").Short('p').StringVar(&c.path) + c.CmdClause.Flag("env", "The manifest environment config to validate (e.g. 'stage' will attempt to read 'fastly.stage.toml' inside the package)").StringVar(&c.env) + return &c +} + +// Exec implements the command interface. +func (c *ValidateCommand) Exec(_ io.Reader, out io.Writer) error { + packagePath := c.path + if packagePath == "" { + projectName, source := c.Globals.Manifest.Name() + if source == manifest.SourceUndefined { + return fsterr.RemediationError{ + Inner: fmt.Errorf("failed to read project name: %w", fsterr.ErrReadingManifest), + Remediation: "Run `fastly compute build` to produce a Compute package, alternatively use the --package flag to reference a package outside of the current project.", + } + } + packagePath = filepath.Join("pkg", fmt.Sprintf("%s.tar.gz", sanitize.BaseName(projectName))) + } + + p, err := filepath.Abs(packagePath) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Path": c.path, + }) + return fmt.Errorf("error reading file path: %w", err) + } + + if c.env != "" { + manifestFilename := fmt.Sprintf("fastly.%s.toml", c.env) + if c.Globals.Verbose() { + text.Info(out, "Using the '%s' environment manifest (it will be packaged up as %s)\n\n", manifestFilename, manifest.Filename) + } + } + + if err := validatePackageContent(p); err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Path": c.path, + }) + return fsterr.RemediationError{ + Inner: fmt.Errorf("failed to validate package: %w", err), + Remediation: "Run `fastly compute build` to produce a Compute package, alternatively use the --package flag to reference a package outside of the current project.", + } + } + + text.Success(out, "Validated package %s", p) + return nil +} + +// ValidateCommand validates a package archive. +type ValidateCommand struct { + argparser.Base + env string + path string +} + +// validatePackageContent is a utility function to determine whether a package +// is valid. It walks through the package files checking the filename against a +// list of required files. If one of the files doesn't exist it returns an error. +// +// NOTE: This function is also called by the `deploy` command. +func validatePackageContent(pkgPath string) error { + // False positive https://github.com/semgrep/semgrep/issues/8593 + // nosemgrep: trailofbits.go.iterate-over-empty-map.iterate-over-empty-map + files := map[string]bool{ + manifest.Filename: false, + "main.wasm": false, + } + + if err := packageFiles(pkgPath, func(f archiver.File) error { + for k := range files { + if k == f.Name() { + files[k] = true + } + } + return nil + }); err != nil { + return err + } + + for k, found := range files { + if !found { + return fmt.Errorf("error validating package: package must contain a %s file", k) + } + } + + return nil +} + +// packageFiles is a utility function to iterate over the package content. +// It attempts to unarchive and read a tar.gz file from a specific path, +// calling fn on each file in the archive. +func packageFiles(path string, fn func(archiver.File) error) error { + file, err := os.Open(filepath.Clean(path)) + if err != nil { + return fmt.Errorf("error reading package: %w", err) + } + defer file.Close() // #nosec G307 + + tr := archiver.NewTarGz() + err = tr.Open(file, 0) + if err != nil { + return fmt.Errorf("error unarchiving package: %w", err) + } + defer tr.Close() + + for { + f, err := tr.Read() + if err == io.EOF { + break + } + if err != nil { + return fmt.Errorf("error reading package: %w", err) + } + + header, ok := f.Header.(*tar.Header) + if !ok || header.Typeflag != tar.TypeReg { + f.Close() + continue + } + + if err = fn(f); err != nil { + f.Close() + return err + } + + err = f.Close() + if err != nil { + return fmt.Errorf("error closing file: %w", err) + } + } + + return nil +} diff --git a/pkg/commands/compute/validate_test.go b/pkg/commands/compute/validate_test.go new file mode 100644 index 000000000..1e781fe95 --- /dev/null +++ b/pkg/commands/compute/validate_test.go @@ -0,0 +1,32 @@ +package compute_test + +import ( + "path/filepath" + "testing" + + root "github.com/fastly/cli/pkg/commands/compute" + "github.com/fastly/cli/pkg/testutil" +) + +func TestValidate(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "success", + Args: "--package pkg/package.tar.gz", + Env: &testutil.EnvConfig{ + Opts: &testutil.EnvOpts{ + Copy: []testutil.FileIO{ + { + Src: filepath.Join("testdata", "deploy", "pkg", "package.tar.gz"), + Dst: filepath.Join("pkg", "package.tar.gz"), + }, + }, + }, + }, + WantError: "", + WantOutput: "Validated package", + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, "validate"}, scenarios) +} diff --git a/pkg/commands/config/config_test.go b/pkg/commands/config/config_test.go new file mode 100644 index 000000000..22212def6 --- /dev/null +++ b/pkg/commands/config/config_test.go @@ -0,0 +1,58 @@ +package config_test + +import ( + "os" + "path/filepath" + "testing" + + root "github.com/fastly/cli/pkg/commands/config" + "github.com/fastly/cli/pkg/testutil" +) + +func TestConfig(t *testing.T) { + var data []byte + + // Read the test config.toml data + path, err := filepath.Abs(filepath.Join("./", "testdata", "config.toml")) + if err != nil { + t.Fatal(err) + } + data, err = os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + + scenarios := []testutil.CLIScenario{ + { + Name: "validate config file content is displayed", + Env: &testutil.EnvConfig{ + Opts: &testutil.EnvOpts{ + Write: []testutil.FileIO{ + {Src: string(data), Dst: "config.toml"}, + }, + }, + EditScenario: func(scenario *testutil.CLIScenario, rootdir string) { + scenario.ConfigPath = filepath.Join(rootdir, "config.toml") + }, + }, + WantOutput: string(data), + }, + { + Name: "validate config location is displayed", + Args: "--location", + Env: &testutil.EnvConfig{ + Opts: &testutil.EnvOpts{ + Write: []testutil.FileIO{ + {Src: string(data), Dst: "config.toml"}, + }, + }, + EditScenario: func(scenario *testutil.CLIScenario, rootdir string) { + scenario.ConfigPath = filepath.Join(rootdir, "config.toml") + scenario.WantOutput = scenario.ConfigPath + }, + }, + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName}, scenarios) +} diff --git a/pkg/commands/config/doc.go b/pkg/commands/config/doc.go new file mode 100644 index 000000000..571b453f4 --- /dev/null +++ b/pkg/commands/config/doc.go @@ -0,0 +1,2 @@ +// Package config contains commands to inspect the CLI configuration. +package config diff --git a/pkg/commands/config/root.go b/pkg/commands/config/root.go new file mode 100644 index 000000000..d1bfbc4f8 --- /dev/null +++ b/pkg/commands/config/root.go @@ -0,0 +1,59 @@ +package config + +import ( + "fmt" + "io" + "os" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/config" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// RootCommand is the parent command for all subcommands in this package. +// It should be installed under the primary root command. +type RootCommand struct { + argparser.Base + + location bool + reset bool +} + +// CommandName is the string to be used to invoke this command. +const CommandName = "config" + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent argparser.Registerer, g *global.Data) *RootCommand { + var c RootCommand + c.Globals = g + c.CmdClause = parent.Command(CommandName, "Display the Fastly CLI configuration") + c.CmdClause.Flag("location", "Print the location of the CLI configuration file").Short('l').BoolVar(&c.location) + c.CmdClause.Flag("reset", "Reset the config to a version compatible with the current CLI version").Short('r').BoolVar(&c.reset) + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, out io.Writer) (err error) { + if c.reset { + if err := c.Globals.Config.UseStatic(config.FilePath); err != nil { + return err + } + } + + if c.location { + if c.Globals.Flags.Verbose { + text.Break(out) + } + fmt.Fprintln(out, c.Globals.ConfigPath) + return nil + } + + data, err := os.ReadFile(c.Globals.ConfigPath) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + fmt.Fprintln(out, string(data)) + return nil +} diff --git a/pkg/commands/config/testdata/config.toml b/pkg/commands/config/testdata/config.toml new file mode 100644 index 000000000..b907b1b93 --- /dev/null +++ b/pkg/commands/config/testdata/config.toml @@ -0,0 +1,4 @@ +config_version = 2 + +[fastly] + api_endpoint = "https://api.fastly.com" diff --git a/pkg/commands/configstore/configstore_test.go b/pkg/commands/configstore/configstore_test.go new file mode 100644 index 000000000..616069f34 --- /dev/null +++ b/pkg/commands/configstore/configstore_test.go @@ -0,0 +1,373 @@ +package configstore_test + +import ( + "errors" + "fmt" + "testing" + "time" + + "github.com/fastly/go-fastly/v10/fastly" + + root "github.com/fastly/cli/pkg/commands/configstore" + fstfmt "github.com/fastly/cli/pkg/fmt" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" +) + +func TestCreateStoreCommand(t *testing.T) { + const ( + storeName = "test123" + storeID = "store-id-123" + ) + now := time.Now() + + scenarios := []testutil.CLIScenario{ + { + WantError: "error parsing arguments: required flag --name not provided", + }, + { + Args: fmt.Sprintf("--name %s", storeName), + API: mock.API{ + CreateConfigStoreFn: func(_ *fastly.CreateConfigStoreInput) (*fastly.ConfigStore, error) { + return nil, errors.New("invalid request") + }, + }, + WantError: "invalid request", + }, + { + Args: fmt.Sprintf("--name %s", storeName), + API: mock.API{ + CreateConfigStoreFn: func(i *fastly.CreateConfigStoreInput) (*fastly.ConfigStore, error) { + return &fastly.ConfigStore{ + StoreID: storeID, + Name: i.Name, + }, nil + }, + }, + WantOutput: fstfmt.Success("Created Config Store '%s' (%s)", storeName, storeID), + }, + { + Args: fmt.Sprintf("--name %s --json", storeName), + API: mock.API{ + CreateConfigStoreFn: func(i *fastly.CreateConfigStoreInput) (*fastly.ConfigStore, error) { + return &fastly.ConfigStore{ + StoreID: storeID, + Name: i.Name, + CreatedAt: &now, + UpdatedAt: &now, + }, nil + }, + }, + WantOutput: fstfmt.EncodeJSON(&fastly.ConfigStore{ + StoreID: storeID, + Name: storeName, + CreatedAt: &now, + UpdatedAt: &now, + }), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, "create"}, scenarios) +} + +func TestDeleteStoreCommand(t *testing.T) { + const storeID = "test123" + errStoreNotFound := errors.New("store not found") + + scenarios := []testutil.CLIScenario{ + { + WantError: "error parsing arguments: required flag --store-id not provided", + }, + { + Args: "--store-id DOES-NOT-EXIST", + API: mock.API{ + DeleteConfigStoreFn: func(i *fastly.DeleteConfigStoreInput) error { + if i.StoreID != storeID { + return errStoreNotFound + } + return nil + }, + }, + WantError: errStoreNotFound.Error(), + }, + { + Args: fmt.Sprintf("--store-id %s", storeID), + API: mock.API{ + DeleteConfigStoreFn: func(i *fastly.DeleteConfigStoreInput) error { + if i.StoreID != storeID { + return errStoreNotFound + } + return nil + }, + }, + WantOutput: fstfmt.Success("Deleted Config Store '%s'\n", storeID), + }, + { + Args: fmt.Sprintf("--store-id %s --json", storeID), + API: mock.API{ + DeleteConfigStoreFn: func(i *fastly.DeleteConfigStoreInput) error { + if i.StoreID != storeID { + return errStoreNotFound + } + return nil + }, + }, + WantOutput: fstfmt.JSON(`{"id": %q, "deleted": true}`, storeID), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, "delete"}, scenarios) +} + +func TestGetStoreCommand(t *testing.T) { + const ( + storeName = "test123" + storeID = "store-id-123" + ) + + now := time.Now() + + scenarios := []testutil.CLIScenario{ + { + WantError: "error parsing arguments: required flag --store-id not provided", + }, + { + Args: fmt.Sprintf("--store-id %s", storeID), + API: mock.API{ + GetConfigStoreFn: func(_ *fastly.GetConfigStoreInput) (*fastly.ConfigStore, error) { + return nil, errors.New("invalid request") + }, + }, + WantError: "invalid request", + }, + { + Args: fmt.Sprintf("--store-id %s", storeID), + API: mock.API{ + GetConfigStoreFn: func(i *fastly.GetConfigStoreInput) (*fastly.ConfigStore, error) { + return &fastly.ConfigStore{ + StoreID: i.StoreID, + Name: storeName, + CreatedAt: &now, + }, nil + }, + }, + WantOutput: fmtStore( + &fastly.ConfigStore{ + StoreID: storeID, + Name: storeName, + CreatedAt: &now, + }, + nil, + ), + }, + { + Args: fmt.Sprintf("--store-id %s --metadata", storeID), + API: mock.API{ + GetConfigStoreFn: func(i *fastly.GetConfigStoreInput) (*fastly.ConfigStore, error) { + return &fastly.ConfigStore{ + StoreID: i.StoreID, + Name: storeName, + CreatedAt: &now, + }, nil + }, + GetConfigStoreMetadataFn: func(_ *fastly.GetConfigStoreMetadataInput) (*fastly.ConfigStoreMetadata, error) { + return &fastly.ConfigStoreMetadata{ + ItemCount: 42, + }, nil + }, + }, + WantOutput: fmtStore( + &fastly.ConfigStore{ + StoreID: storeID, + Name: storeName, + CreatedAt: &now, + }, + &fastly.ConfigStoreMetadata{ + ItemCount: 42, + }, + ), + }, + { + Args: fmt.Sprintf("--store-id %s --json", storeID), + API: mock.API{ + GetConfigStoreFn: func(i *fastly.GetConfigStoreInput) (*fastly.ConfigStore, error) { + return &fastly.ConfigStore{ + StoreID: i.StoreID, + Name: storeName, + CreatedAt: &now, + }, nil + }, + }, + WantOutput: fstfmt.EncodeJSON(&fastly.ConfigStore{ + StoreID: storeID, + Name: storeName, + CreatedAt: &now, + }), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, "get"}, scenarios) +} + +func TestListStoresCommand(t *testing.T) { + const ( + storeName = "test123" + storeID = "store-id-123" + ) + + now := time.Now() + + stores := []*fastly.ConfigStore{ + {StoreID: storeID, Name: storeName, CreatedAt: &now}, + {StoreID: storeID + "+1", Name: storeName + "+1", CreatedAt: &now}, + } + + scenarios := []testutil.CLIScenario{ + { + API: mock.API{ + ListConfigStoresFn: func(_ *fastly.ListConfigStoresInput) ([]*fastly.ConfigStore, error) { + return nil, nil + }, + }, + WantOutput: fmtStores(nil), + }, + { + API: mock.API{ + ListConfigStoresFn: func(_ *fastly.ListConfigStoresInput) ([]*fastly.ConfigStore, error) { + return nil, errors.New("unknown error") + }, + }, + WantError: "unknown error", + }, + { + API: mock.API{ + ListConfigStoresFn: func(_ *fastly.ListConfigStoresInput) ([]*fastly.ConfigStore, error) { + return stores, nil + }, + }, + WantOutput: fmtStores(stores), + }, + { + Args: "--json", + API: mock.API{ + ListConfigStoresFn: func(_ *fastly.ListConfigStoresInput) ([]*fastly.ConfigStore, error) { + return stores, nil + }, + }, + WantOutput: fstfmt.EncodeJSON(stores), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, "list"}, scenarios) +} + +func TestListStoreServicesCommand(t *testing.T) { + const ( + storeName = "test123" + storeID = "store-id-123" + ) + + services := []*fastly.Service{ + {ServiceID: fastly.ToPointer("abc1"), Name: fastly.ToPointer("test1"), Type: fastly.ToPointer("wasm")}, + {ServiceID: fastly.ToPointer("abc2"), Name: fastly.ToPointer("test2"), Type: fastly.ToPointer("vcl")}, + } + + scenarios := []testutil.CLIScenario{ + { + Args: fmt.Sprintf("--store-id %s", storeID), + API: mock.API{ + ListConfigStoreServicesFn: func(_ *fastly.ListConfigStoreServicesInput) ([]*fastly.Service, error) { + return nil, nil + }, + }, + WantOutput: fmtServices(nil), + }, + { + Args: fmt.Sprintf("--store-id %s", storeID), + API: mock.API{ + ListConfigStoreServicesFn: func(_ *fastly.ListConfigStoreServicesInput) ([]*fastly.Service, error) { + return nil, errors.New("unknown error") + }, + }, + WantError: "unknown error", + }, + { + Args: fmt.Sprintf("--store-id %s", storeID), + API: mock.API{ + ListConfigStoreServicesFn: func(_ *fastly.ListConfigStoreServicesInput) ([]*fastly.Service, error) { + return services, nil + }, + }, + WantOutput: fmtServices(services), + }, + { + Args: fmt.Sprintf("--store-id %s --json", storeID), + API: mock.API{ + ListConfigStoreServicesFn: func(_ *fastly.ListConfigStoreServicesInput) ([]*fastly.Service, error) { + return services, nil + }, + }, + WantOutput: fstfmt.EncodeJSON(services), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, "list-services"}, scenarios) +} + +func TestUpdateStoreCommand(t *testing.T) { + const ( + storeID = "store-id-123" + storeName = "test123" + ) + now := time.Now() + + scenarios := []testutil.CLIScenario{ + { + Args: fmt.Sprintf("--store-id %s", storeID), + WantError: "error parsing arguments: required flag --name not provided", + }, + { + Args: fmt.Sprintf("--store-id %s --name %s", storeID, storeName), + API: mock.API{ + UpdateConfigStoreFn: func(_ *fastly.UpdateConfigStoreInput) (*fastly.ConfigStore, error) { + return nil, errors.New("invalid request") + }, + }, + WantError: "invalid request", + }, + { + Args: fmt.Sprintf("--store-id %s --name %s", storeID, storeName), + API: mock.API{ + UpdateConfigStoreFn: func(i *fastly.UpdateConfigStoreInput) (*fastly.ConfigStore, error) { + return &fastly.ConfigStore{ + StoreID: storeID, + Name: i.Name, + CreatedAt: &now, + }, nil + }, + }, + WantOutput: fstfmt.Success("Updated Config Store '%s' (%s)", storeName, storeID), + }, + { + Args: fmt.Sprintf("--store-id %s --name %s --json", storeID, storeName), + API: mock.API{ + UpdateConfigStoreFn: func(i *fastly.UpdateConfigStoreInput) (*fastly.ConfigStore, error) { + return &fastly.ConfigStore{ + StoreID: storeID, + Name: i.Name, + CreatedAt: &now, + UpdatedAt: &now, + }, nil + }, + }, + WantOutput: fstfmt.EncodeJSON(&fastly.ConfigStore{ + StoreID: storeID, + Name: storeName, + CreatedAt: &now, + UpdatedAt: &now, + }), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, "update"}, scenarios) +} diff --git a/pkg/commands/configstore/create.go b/pkg/commands/configstore/create.go new file mode 100644 index 000000000..9e24ba431 --- /dev/null +++ b/pkg/commands/configstore/create.go @@ -0,0 +1,64 @@ +package configstore + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// NewCreateCommand returns a usable command registered under the parent. +func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { + c := CreateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + + c.CmdClause = parent.Command("create", "Create a new config store") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: "name", + Short: 'n', + Description: "Store name", + Dst: &c.input.Name, + Required: true, + }) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + + return &c +} + +// CreateCommand calls the Fastly API to create an appropriate resource. +type CreateCommand struct { + argparser.Base + argparser.JSONOutput + input fastly.CreateConfigStoreInput +} + +// Exec invokes the application logic for the command. +func (c *CreateCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + o, err := c.Globals.APIClient.CreateConfigStore(&c.input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + text.Success(out, "Created Config Store '%s' (%s)", o.Name, o.StoreID) + return nil +} diff --git a/pkg/commands/configstore/delete.go b/pkg/commands/configstore/delete.go new file mode 100644 index 000000000..c7dc9eb19 --- /dev/null +++ b/pkg/commands/configstore/delete.go @@ -0,0 +1,66 @@ +package configstore + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// NewDeleteCommand returns a usable command registered under the parent. +func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { + c := DeleteCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + + c.CmdClause = parent.Command("delete", "Delete a config store") + + // Required. + c.RegisterFlag(argparser.StoreIDFlag(&c.input.StoreID)) // --store-id + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + + return &c +} + +// DeleteCommand calls the Fastly API to delete an appropriate resource. +type DeleteCommand struct { + argparser.Base + argparser.JSONOutput + input fastly.DeleteConfigStoreInput +} + +// Exec invokes the application logic for the command. +func (c *DeleteCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + err := c.Globals.APIClient.DeleteConfigStore(&c.input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if c.JSONOutput.Enabled { + o := struct { + ID string `json:"id"` + Deleted bool `json:"deleted"` + }{ + c.input.StoreID, + true, + } + _, err := c.WriteJSON(out, o) + return err + } + + text.Success(out, "Deleted Config Store '%s'", c.input.StoreID) + return nil +} diff --git a/pkg/commands/configstore/describe.go b/pkg/commands/configstore/describe.go new file mode 100644 index 000000000..c4201951a --- /dev/null +++ b/pkg/commands/configstore/describe.go @@ -0,0 +1,89 @@ +package configstore + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// NewDescribeCommand returns a usable command registered under the parent. +func NewDescribeCommand(parent argparser.Registerer, g *global.Data) *DescribeCommand { + c := DescribeCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + + c.CmdClause = parent.Command("describe", "Retrieve a single config store").Alias("get") + + // Required. + c.RegisterFlag(argparser.StoreIDFlag(&c.input.StoreID)) // --store-id + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + c.RegisterFlagBool(argparser.BoolFlagOpts{ + Name: "metadata", + Short: 'm', + Description: "Include config store metadata", + Dst: &c.metadata, + }) + + return &c +} + +// DescribeCommand calls the Fastly API to describe an appropriate resource. +type DescribeCommand struct { + argparser.Base + argparser.JSONOutput + input fastly.GetConfigStoreInput + metadata bool +} + +// Exec invokes the application logic for the command. +func (c *DescribeCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + cs, err := c.Globals.APIClient.GetConfigStore(&c.input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + var csm *fastly.ConfigStoreMetadata + if c.metadata { + csm, err = c.Globals.APIClient.GetConfigStoreMetadata(&fastly.GetConfigStoreMetadataInput{ + StoreID: c.input.StoreID, + }) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + } + + if c.JSONOutput.Enabled { + // Create an ad-hoc structure for JSON representation of the config store + // and its metadata. + data := struct { + *fastly.ConfigStore + Metadata *fastly.ConfigStoreMetadata `json:"metadata,omitempty"` + }{ + ConfigStore: cs, + Metadata: csm, + } + + if ok, err := c.WriteJSON(out, data); ok { + return err + } + } + + text.PrintConfigStore(out, cs, csm) + + return nil +} diff --git a/pkg/commands/configstore/doc.go b/pkg/commands/configstore/doc.go new file mode 100644 index 000000000..c8219cfb2 --- /dev/null +++ b/pkg/commands/configstore/doc.go @@ -0,0 +1,5 @@ +// Package configstore contains commands to inspect and manipulate Fastly edge +// config stores. +// +// https://www.fastly.com/documentation/reference/api/services/resources/config-store +package configstore diff --git a/pkg/commands/configstore/helper_test.go b/pkg/commands/configstore/helper_test.go new file mode 100644 index 000000000..adc49459d --- /dev/null +++ b/pkg/commands/configstore/helper_test.go @@ -0,0 +1,26 @@ +package configstore_test + +import ( + "bytes" + + "github.com/fastly/cli/pkg/text" + "github.com/fastly/go-fastly/v10/fastly" +) + +func fmtStore(cs *fastly.ConfigStore, csm *fastly.ConfigStoreMetadata) string { + var b bytes.Buffer + text.PrintConfigStore(&b, cs, csm) + return b.String() +} + +func fmtStores(s []*fastly.ConfigStore) string { + var b bytes.Buffer + text.PrintConfigStoresTbl(&b, s) + return b.String() +} + +func fmtServices(s []*fastly.Service) string { + var b bytes.Buffer + text.PrintConfigStoreServicesTbl(&b, s) + return b.String() +} diff --git a/pkg/commands/configstore/list.go b/pkg/commands/configstore/list.go new file mode 100644 index 000000000..98d9388ea --- /dev/null +++ b/pkg/commands/configstore/list.go @@ -0,0 +1,55 @@ +package configstore + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// NewListCommand returns a usable command registered under the parent. +func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { + c := ListCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + + c.CmdClause = parent.Command("list", "List config stores") + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + + return &c +} + +// ListCommand calls the Fastly API to list appropriate resources. +type ListCommand struct { + argparser.Base + argparser.JSONOutput +} + +// Exec invokes the application logic for the command. +func (c *ListCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + o, err := c.Globals.APIClient.ListConfigStores(&fastly.ListConfigStoresInput{}) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + text.PrintConfigStoresTbl(out, o) + + return nil +} diff --git a/pkg/commands/configstore/list_services.go b/pkg/commands/configstore/list_services.go new file mode 100644 index 000000000..54acb2926 --- /dev/null +++ b/pkg/commands/configstore/list_services.go @@ -0,0 +1,59 @@ +package configstore + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// NewListServicesCommand returns a usable command registered under the parent. +func NewListServicesCommand(parent argparser.Registerer, g *global.Data) *ListServicesCommand { + c := ListServicesCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + + c.CmdClause = parent.Command("list-services", "List config store's services") + + // Required. + c.RegisterFlag(argparser.StoreIDFlag(&c.input.StoreID)) // --store-id + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + + return &c +} + +// ListServicesCommand calls the Fastly API to list appropriate resources. +type ListServicesCommand struct { + argparser.Base + argparser.JSONOutput + input fastly.ListConfigStoreServicesInput +} + +// Exec invokes the application logic for the command. +func (c *ListServicesCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + o, err := c.Globals.APIClient.ListConfigStoreServices(&c.input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + text.PrintConfigStoreServicesTbl(out, o) + + return nil +} diff --git a/pkg/commands/configstore/root.go b/pkg/commands/configstore/root.go new file mode 100644 index 000000000..83d8f6066 --- /dev/null +++ b/pkg/commands/configstore/root.go @@ -0,0 +1,36 @@ +package configstore + +import ( + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" +) + +// CommandName is the string to be used to invoke this command. +const CommandName = "config-store" + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent argparser.Registerer, g *global.Data) *RootCommand { + c := RootCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + + c.CmdClause = parent.Command(CommandName, "Manipulate Fastly Config Stores") + + return &c +} + +// RootCommand is the parent command for all 'store' subcommands. +// It should be installed under the primary root command. +type RootCommand struct { + argparser.Base + // no flags +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { + panic("unreachable") +} diff --git a/pkg/commands/configstore/update.go b/pkg/commands/configstore/update.go new file mode 100644 index 000000000..23ffa1e38 --- /dev/null +++ b/pkg/commands/configstore/update.go @@ -0,0 +1,65 @@ +package configstore + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// NewUpdateCommand returns a usable command registered under the parent. +func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { + c := UpdateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + + c.CmdClause = parent.Command("update", "Update a config store") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: "name", + Short: 'n', + Description: "New name for the config store", + Dst: &c.input.Name, + Required: true, + }) + c.RegisterFlag(argparser.StoreIDFlag(&c.input.StoreID)) // --store-id + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + + return &c +} + +// UpdateCommand calls the Fastly API to update an appropriate resource. +type UpdateCommand struct { + argparser.Base + argparser.JSONOutput + input fastly.UpdateConfigStoreInput +} + +// Exec invokes the application logic for the command. +func (c *UpdateCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + o, err := c.Globals.APIClient.UpdateConfigStore(&c.input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + text.Success(out, "Updated Config Store '%s' (%s)", o.Name, o.StoreID) + return nil +} diff --git a/pkg/commands/configstoreentry/configstoreentry_test.go b/pkg/commands/configstoreentry/configstoreentry_test.go new file mode 100644 index 000000000..80154520f --- /dev/null +++ b/pkg/commands/configstoreentry/configstoreentry_test.go @@ -0,0 +1,372 @@ +package configstoreentry_test + +import ( + "bytes" + "errors" + "fmt" + "testing" + "time" + + "github.com/fastly/go-fastly/v10/fastly" + + root "github.com/fastly/cli/pkg/commands/configstoreentry" + fstfmt "github.com/fastly/cli/pkg/fmt" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" + "github.com/fastly/cli/pkg/text" +) + +func TestCreateEntryCommand(t *testing.T) { + const ( + storeID = "store-id-123" + itemKey = "key" + itemValue = "the-value" + ) + now := time.Now() + + scenarios := []testutil.CLIScenario{ + { + Args: "--key a-key --value a-value", + WantError: "error parsing arguments: required flag --store-id not provided", + }, + { + Args: fmt.Sprintf("--store-id %s --key %s --value %s", storeID, itemKey, itemValue), + API: mock.API{ + CreateConfigStoreItemFn: func(_ *fastly.CreateConfigStoreItemInput) (*fastly.ConfigStoreItem, error) { + return nil, errors.New("invalid request") + }, + }, + WantError: "invalid request", + }, + { + Args: fmt.Sprintf("--store-id %s --key %s --value %s", storeID, itemKey, itemValue), + API: mock.API{ + CreateConfigStoreItemFn: func(i *fastly.CreateConfigStoreItemInput) (*fastly.ConfigStoreItem, error) { + return &fastly.ConfigStoreItem{ + StoreID: i.StoreID, + Key: i.Key, + Value: i.Value, + }, nil + }, + }, + WantOutput: fstfmt.Success("Created key '%s' in Config Store '%s'", itemKey, storeID), + }, + { + Args: fmt.Sprintf("--store-id %s --key %s --value %s --json", storeID, itemKey, itemValue), + API: mock.API{ + CreateConfigStoreItemFn: func(i *fastly.CreateConfigStoreItemInput) (*fastly.ConfigStoreItem, error) { + return &fastly.ConfigStoreItem{ + StoreID: i.StoreID, + Key: i.Key, + Value: i.Value, + CreatedAt: &now, + UpdatedAt: &now, + }, nil + }, + }, + WantOutput: fstfmt.EncodeJSON(&fastly.ConfigStoreItem{ + StoreID: storeID, + Key: itemKey, + Value: itemValue, + CreatedAt: &now, + UpdatedAt: &now, + }), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, "create"}, scenarios) +} + +func TestDeleteEntryCommand(t *testing.T) { + const ( + storeID = "store-id-123" + itemKey = "key" + ) + + now := time.Now() + + testItems := make([]*fastly.ConfigStoreItem, 3) + for i := range testItems { + testItems[i] = &fastly.ConfigStoreItem{ + StoreID: storeID, + Key: fmt.Sprintf("key-%02d", i), + Value: fmt.Sprintf("value %02d", i), + CreatedAt: &now, + UpdatedAt: &now, + } + } + + scenarios := []testutil.CLIScenario{ + { + Args: "--key a-key", + WantError: "error parsing arguments: required flag --store-id not provided", + }, + { + Args: "--store-id " + storeID, + WantError: "invalid command, neither --all or --key provided", + }, + { + Args: "--json --all --store-id " + storeID, + WantError: "invalid flag combination, --all and --json", + }, + { + Args: "--key a-key --all --store-id " + storeID, + WantError: "invalid flag combination, --all and --key", + }, + { + Args: fmt.Sprintf("--store-id %s --key %s", storeID, itemKey), + API: mock.API{ + DeleteConfigStoreItemFn: func(_ *fastly.DeleteConfigStoreItemInput) error { + return errors.New("invalid request") + }, + }, + WantError: "invalid request", + }, + { + Args: fmt.Sprintf("--store-id %s --key %s", storeID, itemKey), + API: mock.API{ + DeleteConfigStoreItemFn: func(_ *fastly.DeleteConfigStoreItemInput) error { + return nil + }, + }, + WantOutput: fstfmt.Success("Deleted key '%s' from Config Store '%s'", itemKey, storeID), + }, + { + Args: fmt.Sprintf("--store-id %s --key %s --json", storeID, itemKey), + API: mock.API{ + DeleteConfigStoreItemFn: func(_ *fastly.DeleteConfigStoreItemInput) error { + return nil + }, + }, + WantOutput: fstfmt.EncodeJSON(struct { + StoreID string `json:"store_id"` + Key string `json:"key"` + Deleted bool `json:"deleted"` + }{ + storeID, + itemKey, + true, + }), + }, + { + Args: fmt.Sprintf("--store-id %s --all --auto-yes", storeID), + API: mock.API{ + ListConfigStoreItemsFn: func(_ *fastly.ListConfigStoreItemsInput) ([]*fastly.ConfigStoreItem, error) { + return testItems, nil + }, + DeleteConfigStoreItemFn: func(_ *fastly.DeleteConfigStoreItemInput) error { + return nil + }, + }, + WantOutput: fmt.Sprintf(`Deleting key: key-00 +Deleting key: key-01 +Deleting key: key-02 + +SUCCESS: Deleted all keys from Config Store '%s' +`, storeID), + }, + { + Args: fmt.Sprintf("--store-id %s --all --auto-yes", storeID), + API: mock.API{ + ListConfigStoreItemsFn: func(_ *fastly.ListConfigStoreItemsInput) ([]*fastly.ConfigStoreItem, error) { + return testItems, nil + }, + DeleteConfigStoreItemFn: func(_ *fastly.DeleteConfigStoreItemInput) error { + return errors.New("whoops") + }, + }, + WantError: "failed to delete keys: key-00, key-01, key-02", + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, "delete"}, scenarios) +} + +func TestDescribeEntryCommand(t *testing.T) { + const ( + storeID = "store-id-123" + itemKey = "key" + ) + now := time.Now() + + testItem := &fastly.ConfigStoreItem{ + StoreID: storeID, + Key: itemKey, + Value: "a value", + CreatedAt: &now, + UpdatedAt: &now, + } + + scenarios := []testutil.CLIScenario{ + { + Args: "--key a-key", + WantError: "error parsing arguments: required flag --store-id not provided", + }, + { + Args: fmt.Sprintf("--store-id %s --key %s", storeID, itemKey), + API: mock.API{ + GetConfigStoreItemFn: func(_ *fastly.GetConfigStoreItemInput) (*fastly.ConfigStoreItem, error) { + return nil, errors.New("invalid request") + }, + }, + WantError: "invalid request", + }, + { + Args: fmt.Sprintf("--store-id %s --key %s", storeID, itemKey), + API: mock.API{ + GetConfigStoreItemFn: func(i *fastly.GetConfigStoreItemInput) (*fastly.ConfigStoreItem, error) { + return &fastly.ConfigStoreItem{ + StoreID: i.StoreID, + Key: i.Key, + Value: "a value", + CreatedAt: &now, + UpdatedAt: &now, + }, nil + }, + }, + WantOutput: printConfigStoreItem(testItem), + }, + { + Args: fmt.Sprintf("--store-id %s --key %s --json", storeID, itemKey), + API: mock.API{ + GetConfigStoreItemFn: func(i *fastly.GetConfigStoreItemInput) (*fastly.ConfigStoreItem, error) { + return &fastly.ConfigStoreItem{ + StoreID: i.StoreID, + Key: i.Key, + Value: "a value", + CreatedAt: &now, + UpdatedAt: &now, + }, nil + }, + }, + WantOutput: fstfmt.EncodeJSON(testItem), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, "describe"}, scenarios) +} + +func TestListEntriesCommand(t *testing.T) { + const storeID = "store-id-123" + + now := time.Now() + + testItems := make([]*fastly.ConfigStoreItem, 3) + for i := range testItems { + testItems[i] = &fastly.ConfigStoreItem{ + StoreID: storeID, + Key: fmt.Sprintf("key-%02d", i), + Value: fmt.Sprintf("value %02d", i), + CreatedAt: &now, + UpdatedAt: &now, + } + } + + scenarios := []testutil.CLIScenario{ + { + WantError: "error parsing arguments: required flag --store-id not provided", + }, + { + Args: fmt.Sprintf("--store-id %s", storeID), + API: mock.API{ + ListConfigStoreItemsFn: func(_ *fastly.ListConfigStoreItemsInput) ([]*fastly.ConfigStoreItem, error) { + return nil, errors.New("invalid request") + }, + }, + WantError: "invalid request", + }, + { + Args: fmt.Sprintf("--store-id %s", storeID), + API: mock.API{ + ListConfigStoreItemsFn: func(_ *fastly.ListConfigStoreItemsInput) ([]*fastly.ConfigStoreItem, error) { + return testItems, nil + }, + }, + WantOutput: printConfigStoreItemsTbl(testItems), + }, + { + Args: fmt.Sprintf("--store-id %s --json", storeID), + API: mock.API{ + ListConfigStoreItemsFn: func(_ *fastly.ListConfigStoreItemsInput) ([]*fastly.ConfigStoreItem, error) { + return testItems, nil + }, + }, + WantOutput: fstfmt.EncodeJSON(testItems), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, "list"}, scenarios) +} + +func TestUpdateEntryCommand(t *testing.T) { + const ( + storeID = "store-id-123" + itemKey = "key" + itemValue = "the-value" + ) + now := time.Now() + + scenarios := []testutil.CLIScenario{ + { + Args: "--key a-key --value a-value", + WantError: "error parsing arguments: required flag --store-id not provided", + }, + { + Args: fmt.Sprintf("--store-id %s --key %s --value %s", storeID, itemKey, itemValue), + API: mock.API{ + UpdateConfigStoreItemFn: func(_ *fastly.UpdateConfigStoreItemInput) (*fastly.ConfigStoreItem, error) { + return nil, errors.New("invalid request") + }, + }, + WantError: "invalid request", + }, + { + Args: fmt.Sprintf("--store-id %s --key %s --value %s", storeID, itemKey, itemValue), + API: mock.API{ + UpdateConfigStoreItemFn: func(i *fastly.UpdateConfigStoreItemInput) (*fastly.ConfigStoreItem, error) { + return &fastly.ConfigStoreItem{ + StoreID: i.StoreID, + Key: i.Key, + Value: i.Value, + }, nil + }, + }, + WantOutput: fstfmt.Success("Updated config store item %s in store %s", itemKey, storeID), + }, + { + Args: fmt.Sprintf("--store-id %s --key %s --value %s --json", storeID, itemKey, itemValue+"updated"), + API: mock.API{ + UpdateConfigStoreItemFn: func(i *fastly.UpdateConfigStoreItemInput) (*fastly.ConfigStoreItem, error) { + return &fastly.ConfigStoreItem{ + StoreID: i.StoreID, + Key: i.Key, + Value: i.Value, + CreatedAt: &now, + UpdatedAt: &now, + }, nil + }, + }, + WantOutput: fstfmt.EncodeJSON(&fastly.ConfigStoreItem{ + StoreID: storeID, + Key: itemKey, + Value: itemValue + "updated", + CreatedAt: &now, + UpdatedAt: &now, + }), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, "update"}, scenarios) +} + +func printConfigStoreItem(i *fastly.ConfigStoreItem) string { + var b bytes.Buffer + text.PrintConfigStoreItem(&b, "", i) + return b.String() +} + +func printConfigStoreItemsTbl(i []*fastly.ConfigStoreItem) string { + var b bytes.Buffer + text.PrintConfigStoreItemsTbl(&b, i) + return b.String() +} diff --git a/pkg/commands/configstoreentry/create.go b/pkg/commands/configstoreentry/create.go new file mode 100644 index 000000000..18f5da286 --- /dev/null +++ b/pkg/commands/configstoreentry/create.go @@ -0,0 +1,105 @@ +package configstoreentry + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// NewCreateCommand returns a usable command registered under the parent. +func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { + c := CreateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + + c.CmdClause = parent.Command("create", "Create a new config store item").Alias("insert") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: "key", + Short: 'k', + Description: "Item name", + Dst: &c.input.Key, + Required: true, + }) + c.RegisterFlag(argparser.StoreIDFlag(&c.input.StoreID)) // --store-id + + // One of these must be set. + c.RegisterFlagBool(argparser.BoolFlagOpts{ + Name: "stdin", + Description: "Read item value from STDIN. If set, --value will be ignored", + Dst: &c.stdin, + Required: false, + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: "value", + Description: "Item value. Required unless --stdin is set", + Dst: &c.input.Value, + Required: false, + }) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + + return &c +} + +// CreateCommand calls the Fastly API to create an appropriate resource. +type CreateCommand struct { + argparser.Base + argparser.JSONOutput + input fastly.CreateConfigStoreItemInput + stdin bool +} + +// Exec invokes the application logic for the command. +func (c *CreateCommand) Exec(in io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + if c.stdin { + // Determine if 'in' has data available. + if in == nil || text.IsTTY(in) { + return errNoSTDINData + } + + // Must read one past limit, since LimitReader returns EOF + // once it reads its limited number of bytes. + value, err := io.ReadAll(io.LimitReader(in, maxValueLen+1)) + if err != nil { + return err + } + + c.input.Value = string(value) + } else if c.input.Value == "" { + return errNoValue + } + + if len(c.input.Key) > maxKeyLen { + return errMaxKeyLen + } + if len(c.input.Value) > maxValueLen { + return errMaxValueLen + } + + o, err := c.Globals.APIClient.CreateConfigStoreItem(&c.input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + text.Success(out, "Created key '%s' in Config Store '%s'", o.Key, o.StoreID) + return nil +} diff --git a/pkg/commands/configstoreentry/delete.go b/pkg/commands/configstoreentry/delete.go new file mode 100644 index 000000000..b4c8f61db --- /dev/null +++ b/pkg/commands/configstoreentry/delete.go @@ -0,0 +1,184 @@ +package configstoreentry + +import ( + "fmt" + "io" + "strings" + "sync" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// deleteKeysConcurrencyLimit is used to limit the concurrency when deleting ALL keys. +// This is effectively the 'thread pool' size. +const deleteKeysConcurrencyLimit int = 100 + +// batchLimit is used to split the list of items into batches. +// The batch size of 100 aligns with the KV Store pagination default limit. +const batchLimit int = 100 + +// NewDeleteCommand returns a usable command registered under the parent. +func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { + c := DeleteCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + + c.CmdClause = parent.Command("delete", "Delete a config store item") + + // Required. + c.RegisterFlag(argparser.StoreIDFlag(&c.input.StoreID)) // --store-id + + // Optional. + c.CmdClause.Flag("all", "Delete all entries within the store").Short('a').BoolVar(&c.deleteAll) + c.CmdClause.Flag("batch-size", "Key batch processing size (ignored when set without the --all flag)").Short('b').Action(c.batchSize.Set).IntVar(&c.batchSize.Value) + c.CmdClause.Flag("concurrency", "Control thread pool size (ignored when set without the --all flag)").Short('c').Action(c.concurrency.Set).IntVar(&c.concurrency.Value) + c.RegisterFlagBool(c.JSONFlag()) // --json + c.RegisterFlag(argparser.StringFlagOpts{ + Name: "key", + Short: 'k', + Description: "Item name", + Dst: &c.input.Key, + }) + + return &c +} + +// DeleteCommand calls the Fastly API to delete an appropriate resource. +type DeleteCommand struct { + argparser.Base + argparser.JSONOutput + + batchSize argparser.OptionalInt + concurrency argparser.OptionalInt + deleteAll bool + input fastly.DeleteConfigStoreItemInput +} + +// Exec invokes the application logic for the command. +func (c *DeleteCommand) Exec(in io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + // TODO: Support --json for bulk deletions. + if c.deleteAll && c.JSONOutput.Enabled { + return fsterr.ErrInvalidDeleteAllJSONKeyCombo + } + if c.deleteAll && c.input.Key != "" { + return fsterr.ErrInvalidDeleteAllKeyCombo + } + if !c.deleteAll && c.input.Key == "" { + return fsterr.ErrMissingDeleteAllKeyCombo + } + + if c.deleteAll { + if !c.Globals.Flags.AutoYes && !c.Globals.Flags.NonInteractive { + text.Warning(out, "This will delete ALL entries from your store!\n\n") + cont, err := text.AskYesNo(out, "Are you sure you want to continue? [y/N]: ", in) + if err != nil { + return err + } + if !cont { + return nil + } + text.Break(out) + } + return c.deleteAllKeys(out) + } + + err := c.Globals.APIClient.DeleteConfigStoreItem(&c.input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if c.JSONOutput.Enabled { + o := struct { + StoreID string `json:"store_id"` + Key string `json:"key"` + Deleted bool `json:"deleted"` + }{ + c.input.StoreID, + c.input.Key, + true, + } + _, err := c.WriteJSON(out, o) + return err + } + + text.Success(out, "Deleted key '%s' from Config Store '%s'", c.input.Key, c.input.StoreID) + return nil +} + +func (c *DeleteCommand) deleteAllKeys(out io.Writer) error { + // NOTE: The Config Store returns ALL items (there is no pagination). + items, err := c.Globals.APIClient.ListConfigStoreItems(&fastly.ListConfigStoreItemsInput{ + StoreID: c.input.StoreID, + }) + if err != nil { + return fmt.Errorf("failed to acquire list of Config Store items: %w", err) + } + + var ( + mu sync.Mutex + wg sync.WaitGroup + ) + poolSize := deleteKeysConcurrencyLimit + if c.concurrency.WasSet { + poolSize = c.concurrency.Value + } + semaphore := make(chan struct{}, poolSize) + + total := len(items) + failedKeys := []string{} + + batchSize := batchLimit + if c.batchSize.WasSet { + batchSize = c.batchSize.Value + } + + // With KV Store we have pagination support and so that natively provides us a + // predefined 'batch' size. Because we don't have pagination with the Config + // Store it means we'll define our own batch size which the user can override. + for i := 0; i < total; i += batchSize { + end := i + batchSize + if end > total { + end = total + } + seg := items[i:end] + + wg.Add(1) + go func(items []*fastly.ConfigStoreItem) { + semaphore <- struct{}{} + defer func() { <-semaphore }() + defer wg.Done() + + for _, item := range items { + text.Output(out, "Deleting key: %s", item.Key) + err := c.Globals.APIClient.DeleteConfigStoreItem(&fastly.DeleteConfigStoreItemInput{StoreID: c.input.StoreID, Key: item.Key}) + if err != nil { + c.Globals.ErrLog.Add(fmt.Errorf("failed to delete key '%s': %s", item.Key, err)) + mu.Lock() + failedKeys = append(failedKeys, item.Key) + mu.Unlock() + } + } + }(seg) + } + + wg.Wait() + close(semaphore) + + if len(failedKeys) > 0 { + return fmt.Errorf("failed to delete keys: %s", strings.Join(failedKeys, ", ")) + } + + text.Success(out, "\nDeleted all keys from Config Store '%s'", c.input.StoreID) + return nil +} diff --git a/pkg/commands/configstoreentry/describe.go b/pkg/commands/configstoreentry/describe.go new file mode 100644 index 000000000..43496952e --- /dev/null +++ b/pkg/commands/configstoreentry/describe.go @@ -0,0 +1,66 @@ +package configstoreentry + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// NewDescribeCommand returns a usable command registered under the parent. +func NewDescribeCommand(parent argparser.Registerer, g *global.Data) *DescribeCommand { + c := DescribeCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + + c.CmdClause = parent.Command("describe", "Retrieve a single config store item").Alias("get") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: "key", + Short: 'k', + Description: "Item name", + Dst: &c.input.Key, + Required: true, + }) + c.RegisterFlag(argparser.StoreIDFlag(&c.input.StoreID)) // --store-id + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + + return &c +} + +// DescribeCommand calls the Fastly API to describe an appropriate resource. +type DescribeCommand struct { + argparser.Base + argparser.JSONOutput + input fastly.GetConfigStoreItemInput +} + +// Exec invokes the application logic for the command. +func (c *DescribeCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + o, err := c.Globals.APIClient.GetConfigStoreItem(&c.input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + text.PrintConfigStoreItem(out, "", o) + + return nil +} diff --git a/pkg/commands/configstoreentry/doc.go b/pkg/commands/configstoreentry/doc.go new file mode 100644 index 000000000..332ae7bf3 --- /dev/null +++ b/pkg/commands/configstoreentry/doc.go @@ -0,0 +1,5 @@ +// Package configstoreentry contains commands to inspect and manipulate Fastly +// edge config store items. +// +// https://www.fastly.com/documentation/reference/api/services/resources/config-store-item +package configstoreentry diff --git a/pkg/commands/configstoreentry/errors.go b/pkg/commands/configstoreentry/errors.go new file mode 100644 index 000000000..f94e7a345 --- /dev/null +++ b/pkg/commands/configstoreentry/errors.go @@ -0,0 +1,33 @@ +package configstoreentry + +import ( + "errors" + "fmt" + + fsterr "github.com/fastly/cli/pkg/errors" +) + +const ( + maxKeyLen = 256 + maxValueLen = 8000 +) + +var errNoSTDINData = fsterr.RemediationError{ + Inner: errors.New("unable to read from STDIN"), + Remediation: "Provide data to STDIN, or use --value to specify item value", +} + +var errNoValue = fsterr.RemediationError{ + Inner: errors.New("no value provided"), + Remediation: "Use --value or --stdin to specify item value", +} + +var errMaxKeyLen = fsterr.RemediationError{ + Inner: errors.New("key max length"), + Remediation: fmt.Sprintf("Key must be less than or equal to %d bytes", maxKeyLen), +} + +var errMaxValueLen = fsterr.RemediationError{ + Inner: errors.New("value max length"), + Remediation: fmt.Sprintf("Value must be less than or equal to %d bytes", maxValueLen), +} diff --git a/pkg/commands/configstoreentry/list.go b/pkg/commands/configstoreentry/list.go new file mode 100644 index 000000000..1c4f4b981 --- /dev/null +++ b/pkg/commands/configstoreentry/list.go @@ -0,0 +1,59 @@ +package configstoreentry + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// NewListCommand returns a usable command registered under the parent. +func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { + c := ListCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + + c.CmdClause = parent.Command("list", "List config store items") + + // Required. + c.RegisterFlag(argparser.StoreIDFlag(&c.input.StoreID)) // --store-id + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + + return &c +} + +// ListCommand calls the Fastly API to list appropriate resources. +type ListCommand struct { + argparser.Base + argparser.JSONOutput + input fastly.ListConfigStoreItemsInput +} + +// Exec invokes the application logic for the command. +func (c *ListCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + o, err := c.Globals.APIClient.ListConfigStoreItems(&c.input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + text.PrintConfigStoreItemsTbl(out, o) + + return nil +} diff --git a/pkg/commands/configstoreentry/root.go b/pkg/commands/configstoreentry/root.go new file mode 100644 index 000000000..ecab6236f --- /dev/null +++ b/pkg/commands/configstoreentry/root.go @@ -0,0 +1,36 @@ +package configstoreentry + +import ( + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" +) + +// CommandName is the string to be used to invoke this command. +const CommandName = "config-store-entry" + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent argparser.Registerer, g *global.Data) *RootCommand { + c := RootCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + + c.CmdClause = parent.Command(CommandName, "Manipulate Fastly Config Store items") + + return &c +} + +// RootCommand is the parent command for all subcommands. +// It should be installed under the primary root command. +type RootCommand struct { + argparser.Base + // no flags +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { + panic("unreachable") +} diff --git a/pkg/commands/configstoreentry/update.go b/pkg/commands/configstoreentry/update.go new file mode 100644 index 000000000..122068983 --- /dev/null +++ b/pkg/commands/configstoreentry/update.go @@ -0,0 +1,120 @@ +package configstoreentry + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// NewUpdateCommand returns a usable command registered under the parent. +func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { + c := UpdateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + + c.CmdClause = parent.Command("update", "Update a config store item") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: "key", + Short: 'k', + Description: "Item name", + Dst: &c.input.Key, + Required: true, + }) + c.RegisterFlag(argparser.StoreIDFlag(&c.input.StoreID)) // --store-id + + // One of these must be set. + c.RegisterFlagBool(argparser.BoolFlagOpts{ + Name: "stdin", + Description: "Read item value from STDIN. If set, --value will be ignored", + Dst: &c.stdin, + Required: false, + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: "value", + Description: "Item value. Required unless --stdin is set", + Dst: &c.input.Value, + Required: false, + }) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + c.RegisterFlagBool(argparser.BoolFlagOpts{ + Name: "upsert", + Short: 'u', + Description: "If true, insert or update an entry in a config store. Otherwise, only update", + Dst: &c.input.Upsert, + }) + + return &c +} + +// UpdateCommand calls the Fastly API to update an appropriate resource. +type UpdateCommand struct { + argparser.Base + argparser.JSONOutput + input fastly.UpdateConfigStoreItemInput + stdin bool +} + +// Exec invokes the application logic for the command. +func (c *UpdateCommand) Exec(in io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + if c.stdin { + // Determine if 'in' has data available. + if in == nil || text.IsTTY(in) { + return errNoSTDINData + } + + // Must read one past limit, since LimitReader returns EOF + // once it reads its limited number of bytes. + value, err := io.ReadAll(io.LimitReader(in, maxValueLen+1)) + if err != nil { + return err + } + + c.input.Value = string(value) + } else if c.input.Value == "" { + return errNoValue + } + + if len(c.input.Key) > maxKeyLen { + return errMaxKeyLen + } + if len(c.input.Value) > maxValueLen { + return errMaxValueLen + } + + o, err := c.Globals.APIClient.UpdateConfigStoreItem(&c.input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + var action string + if c.input.Upsert { + // The Fastly API does not provide a way to determine if + // an item was created or updated when using 'upsert' operation. + action = "Created or updated" + } else { + action = "Updated" + } + + text.Success(out, "%s config store item %s in store %s", action, o.Key, o.StoreID) + return nil +} diff --git a/pkg/commands/dashboard/common/print.go b/pkg/commands/dashboard/common/print.go new file mode 100644 index 000000000..4100c44f8 --- /dev/null +++ b/pkg/commands/dashboard/common/print.go @@ -0,0 +1,92 @@ +// Package common contains functions used by both dashboard and dashboard/item packages +package common + +import ( + "fmt" + "io" + "strings" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/text" +) + +// PrintSummary displays the information returned from the API in a summarised +// format. +func PrintSummary(out io.Writer, ds []fastly.ObservabilityCustomDashboard) { + t := text.NewTable(out) + t.AddHeader("DASHBOARD ID", "NAME", "DESCRIPTION", "# ITEMS") + for _, d := range ds { + t.AddLine( + d.ID, + d.Name, + d.Description, + len(d.Items), + ) + } + t.Print() +} + +// PrintVerbose displays the information returned from the API in a verbose +// format. +func PrintVerbose(out io.Writer, ds []fastly.ObservabilityCustomDashboard) { + for _, d := range ds { + PrintDashboard(out, 0, &d) + fmt.Fprintf(out, "\n") + } +} + +// PrintDashboard displays the Dashboard returned from the API in a human- +// readable format. +func PrintDashboard(out io.Writer, indent uint, dashboard *fastly.ObservabilityCustomDashboard) { + indentStep := uint(4) + level := indent + text.Indent(out, level, "Name: %s", dashboard.Name) + text.Indent(out, level, "Description: %s", dashboard.Description) + text.Indent(out, level, "Items:") + + level += indentStep + for i, di := range dashboard.Items { + text.Indent(out, level, "[%d]:", i) + level += indentStep + PrintItem(out, level, &di) + level -= indentStep + } + level -= indentStep + + text.Indent(out, level, "Meta:") + level += indentStep + text.Indent(out, level, "Created at: %s", dashboard.CreatedAt) + text.Indent(out, level, "Updated at: %s", dashboard.UpdatedAt) + text.Indent(out, level, "Created by: %s", dashboard.CreatedBy) + text.Indent(out, level, "Updated by: %s", dashboard.UpdatedBy) +} + +// PrintItem displays a single DashboardItem in a human-readable format. +func PrintItem(out io.Writer, indent uint, item *fastly.DashboardItem) { + indentStep := uint(4) + level := indent + if item != nil { + text.Indent(out, level, "ID: %s", item.ID) + text.Indent(out, level, "Title: %s", item.Title) + text.Indent(out, level, "Subtitle: %s", item.Subtitle) + text.Indent(out, level, "Span: %d", item.Span) + + text.Indent(out, level, "Data Source:") + level += indentStep + text.Indent(out, level, "Type: %s", item.DataSource.Type) + text.Indent(out, level, "Metrics: %s", strings.Join(item.DataSource.Config.Metrics, ", ")) + level -= indentStep + + text.Indent(out, level, "Visualization:") + level += indentStep + text.Indent(out, level, "Type: %s", item.Visualization.Type) + text.Indent(out, level, "Plot Type: %s", item.Visualization.Config.PlotType) + if item.Visualization.Config.CalculationMethod != nil { + text.Indent(out, level, "Calculation Method: %s", *item.Visualization.Config.CalculationMethod) + } + if item.Visualization.Config.Format != nil { + text.Indent(out, level, "Format: %s", *item.Visualization.Config.Format) + } + } +} diff --git a/pkg/commands/dashboard/create.go b/pkg/commands/dashboard/create.go new file mode 100644 index 000000000..832ec2040 --- /dev/null +++ b/pkg/commands/dashboard/create.go @@ -0,0 +1,71 @@ +package dashboard + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// NewCreateCommand returns a usable command registered under the parent. +func NewCreateCommand(parent argparser.Registerer, globals *global.Data) *CreateCommand { + var c CreateCommand + c.CmdClause = parent.Command("create", "Create a custom dashboard").Alias("add") + c.Globals = globals + + // Required flags + c.CmdClause.Flag("name", "A human-readable name for the dashboard").Short('n').Required().StringVar(&c.name) // --name + + // Optional flags + c.RegisterFlagBool(c.JSONFlag()) // --json + c.CmdClause.Flag("description", "A short description of the dashboard").Action(c.description.Set).StringVar(&c.description.Value) // --description + + return &c +} + +// CreateCommand calls the Fastly API to create an appropriate resource. +type CreateCommand struct { + argparser.Base + argparser.JSONOutput + + name string + description argparser.OptionalString +} + +// Exec invokes the application logic for the command. +func (c *CreateCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + input := c.constructInput() + dashboard, err := c.Globals.APIClient.CreateObservabilityCustomDashboard(input) + if err != nil { + return err + } + + if ok, err := c.WriteJSON(out, dashboard); ok { + return err + } + + text.Success(out, `Created Custom Dashboard "%s" (id: %s)`, dashboard.Name, dashboard.ID) + return nil +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *CreateCommand) constructInput() *fastly.CreateObservabilityCustomDashboardInput { + input := fastly.CreateObservabilityCustomDashboardInput{ + Name: c.name, + Items: []fastly.DashboardItem{}, + } + + if c.description.WasSet { + input.Description = &c.description.Value + } + + return &input +} diff --git a/pkg/commands/dashboard/dashboard_test.go b/pkg/commands/dashboard/dashboard_test.go new file mode 100644 index 000000000..5ddff3ea5 --- /dev/null +++ b/pkg/commands/dashboard/dashboard_test.go @@ -0,0 +1,238 @@ +package dashboard_test + +import ( + "testing" + + "github.com/fastly/go-fastly/v10/fastly" + + root "github.com/fastly/cli/pkg/commands/dashboard" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" +) + +const ( + userID = "test-user" +) + +func TestCreate(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate CreateObservabilityCustomDashboard API error", + API: mock.API{ + CreateObservabilityCustomDashboardFn: func(_ *fastly.CreateObservabilityCustomDashboardInput) (*fastly.ObservabilityCustomDashboard, error) { + return nil, testutil.Err + }, + }, + Args: "--name Testing", + WantError: testutil.Err.Error(), + }, + { + Name: "validate missing --name flag", + API: mock.API{ + CreateObservabilityCustomDashboardFn: func(_ *fastly.CreateObservabilityCustomDashboardInput) (*fastly.ObservabilityCustomDashboard, error) { + return nil, testutil.Err + }, + }, + Args: "", + WantError: "error parsing arguments: required flag --name not provided", + }, + { + Name: "validate optional --description flag", + API: mock.API{ + CreateObservabilityCustomDashboardFn: func(i *fastly.CreateObservabilityCustomDashboardInput) (*fastly.ObservabilityCustomDashboard, error) { + return &fastly.ObservabilityCustomDashboard{ + ID: "beepboop", + Name: i.Name, + }, nil + }, + }, + Args: "--name Testing", + WantOutput: `Created Custom Dashboard "Testing" (id: beepboop)`, + }, + { + Name: "validate CreateObservabilityCustomDashboard API success", + API: mock.API{ + CreateObservabilityCustomDashboardFn: func(i *fastly.CreateObservabilityCustomDashboardInput) (*fastly.ObservabilityCustomDashboard, error) { + return &fastly.ObservabilityCustomDashboard{ + ID: "beepboop", + Name: i.Name, + Description: *i.Description, + }, nil + }, + }, + Args: "--name Testing --description foo", + WantOutput: `Created Custom Dashboard "Testing" (id: beepboop)`, + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, "create"}, scenarios) +} + +func TestDelete(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --id flag", + WantError: "error parsing arguments: required flag --id not provided", + }, + { + Name: "validate DeleteObservabilityCustomDashboard API error", + API: mock.API{ + DeleteObservabilityCustomDashboardFn: func(_ *fastly.DeleteObservabilityCustomDashboardInput) error { + return testutil.Err + }, + }, + Args: "--id beepboop", + WantError: testutil.Err.Error(), + }, + { + Name: "validate DeleteObservabilityCustomDashboard API success", + API: mock.API{ + DeleteObservabilityCustomDashboardFn: func(_ *fastly.DeleteObservabilityCustomDashboardInput) error { + return nil + }, + }, + Args: "--id beepboop", + WantOutput: "Deleted Custom Dashboard beepboop", + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, "delete"}, scenarios) +} + +func TestDescribe(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --id flag", + WantError: "error parsing arguments: required flag --id not provided", + }, + { + Name: "validate GetObservabilityCustomDashboard API error", + API: mock.API{ + GetObservabilityCustomDashboardFn: func(_ *fastly.GetObservabilityCustomDashboardInput) (*fastly.ObservabilityCustomDashboard, error) { + return nil, testutil.Err + }, + }, + Args: "--id beepboop", + WantError: testutil.Err.Error(), + }, + { + Name: "validate GetObservabilityCustomDashboard API success", + API: mock.API{ + GetObservabilityCustomDashboardFn: getObservabilityCustomDashboard, + }, + Args: "--id beepboop", + WantOutput: "Name: Testing\nDescription: This is a test dashboard\nItems:\nMeta:\n Created at: 2021-06-15 23:00:00 +0000 UTC\n Updated at: 2021-06-15 23:00:00 +0000 UTC\n Created by: test-user\n Updated by: test-user\n", + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, "describe"}, scenarios) +} + +func TestList(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate ListObservabilityCustomDashboards API error", + API: mock.API{ + ListObservabilityCustomDashboardsFn: func(_ *fastly.ListObservabilityCustomDashboardsInput) (*fastly.ListDashboardsResponse, error) { + return nil, testutil.Err + }, + }, + WantError: testutil.Err.Error(), + }, + { + Name: "validate ListObservabilityCustomDashboards API success", + API: mock.API{ + ListObservabilityCustomDashboardsFn: listObservabilityCustomDashboards, + }, + WantOutput: "DASHBOARD ID NAME DESCRIPTION # ITEMS\nbeepboop Testing 1 This is #1 0\nbleepblorp Testing 2 This is #2 0\n", + }, + { + Name: "validate --verbose flag", + API: mock.API{ + ListObservabilityCustomDashboardsFn: listObservabilityCustomDashboards, + }, + Args: "--verbose", + WantOutput: "Fastly API endpoint: https://api.fastly.com\nFastly API token provided via config file (profile: user)\n\nName: Testing 1\nDescription: This is #1\nItems:\nMeta:\n Created at: 2021-06-15 23:00:00 +0000 UTC\n Updated at: 2021-06-15 23:00:00 +0000 UTC\n Created by: test-user\n Updated by: test-user\n\nName: Testing 2\nDescription: This is #2\nItems:\nMeta:\n Created at: 2021-06-15 23:00:00 +0000 UTC\n Updated at: 2021-06-15 23:00:00 +0000 UTC\n Created by: test-user\n Updated by: test-user\n\n", + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, "list"}, scenarios) +} + +func TestUpdate(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --id flag", + WantError: "error parsing arguments: required flag --id not provided", + }, + { + Name: "validate UpdateObservabilityCustomDashboard API error", + API: mock.API{ + UpdateObservabilityCustomDashboardFn: func(_ *fastly.UpdateObservabilityCustomDashboardInput) (*fastly.ObservabilityCustomDashboard, error) { + return nil, testutil.Err + }, + }, + Args: "--id beepboop", + WantError: testutil.Err.Error(), + }, + { + Name: "validate UpdateObservabilityCustomDashboard API success", + API: mock.API{ + UpdateObservabilityCustomDashboardFn: func(i *fastly.UpdateObservabilityCustomDashboardInput) (*fastly.ObservabilityCustomDashboard, error) { + return &fastly.ObservabilityCustomDashboard{ + ID: *i.ID, + Name: *i.Name, + Description: *i.Description, + }, nil + }, + }, + Args: "--id beepboop --name Foo --description Bleepblorp", + WantOutput: "SUCCESS: Updated Custom Dashboard \"Foo\" (id: beepboop)\n", + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, "update"}, scenarios) +} + +func getObservabilityCustomDashboard(i *fastly.GetObservabilityCustomDashboardInput) (*fastly.ObservabilityCustomDashboard, error) { + t := testutil.Date + + return &fastly.ObservabilityCustomDashboard{ + CreatedAt: t, + CreatedBy: userID, + Description: "This is a test dashboard", + ID: *i.ID, + Items: []fastly.DashboardItem{}, + Name: "Testing", + UpdatedAt: t, + UpdatedBy: userID, + }, nil +} + +func listObservabilityCustomDashboards(_ *fastly.ListObservabilityCustomDashboardsInput) (*fastly.ListDashboardsResponse, error) { + t := testutil.Date + vs := &fastly.ListDashboardsResponse{ + Data: []fastly.ObservabilityCustomDashboard{{ + CreatedAt: t, + CreatedBy: userID, + Description: "This is #1", + ID: "beepboop", + Items: []fastly.DashboardItem{}, + Name: "Testing 1", + UpdatedAt: t, + UpdatedBy: userID, + }, { + CreatedAt: t, + CreatedBy: userID, + Description: "This is #2", + ID: "bleepblorp", + Items: []fastly.DashboardItem{}, + Name: "Testing 2", + UpdatedAt: t, + UpdatedBy: userID, + }}, + Meta: fastly.DashboardMeta{}, + } + + return vs, nil +} diff --git a/pkg/commands/dashboard/delete.go b/pkg/commands/dashboard/delete.go new file mode 100644 index 000000000..24e315a0a --- /dev/null +++ b/pkg/commands/dashboard/delete.go @@ -0,0 +1,72 @@ +package dashboard + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// NewDeleteCommand returns a usable command registered under the parent. +func NewDeleteCommand(parent argparser.Registerer, globals *global.Data) *DeleteCommand { + var c DeleteCommand + c.CmdClause = parent.Command("delete", "Delete a custom dashboard").Alias("remove") + c.Globals = globals + + // Required flags + c.CmdClause.Flag("id", "ID of the Dashboard to delete").Required().StringVar(&c.dashboardID) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + + return &c +} + +// DeleteCommand calls the Fastly API to delete an appropriate resource. +type DeleteCommand struct { + argparser.Base + argparser.JSONOutput + + dashboardID string +} + +// Exec invokes the application logic for the command. +func (c *DeleteCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + input := c.constructInput() + err := c.Globals.APIClient.DeleteObservabilityCustomDashboard(input) + if err != nil { + return err + } + + if c.JSONOutput.Enabled { + o := struct { + ID string `json:"dashboard_id"` + Deleted bool `json:"deleted"` + }{ + c.dashboardID, + true, + } + _, err := c.WriteJSON(out, o) + return err + } + + text.Success(out, `Deleted Custom Dashboard %s`, fastly.ToValue(input.ID)) + return nil +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *DeleteCommand) constructInput() *fastly.DeleteObservabilityCustomDashboardInput { + var input fastly.DeleteObservabilityCustomDashboardInput + + input.ID = &c.dashboardID + + return &input +} diff --git a/pkg/commands/dashboard/describe.go b/pkg/commands/dashboard/describe.go new file mode 100644 index 000000000..fb5cafcef --- /dev/null +++ b/pkg/commands/dashboard/describe.go @@ -0,0 +1,63 @@ +package dashboard + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/dashboard/common" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" +) + +// NewDescribeCommand returns a usable command registered under the parent. +func NewDescribeCommand(parent argparser.Registerer, globals *global.Data) *DescribeCommand { + var c DescribeCommand + c.CmdClause = parent.Command("describe", "Show detailed information about a custom dashboard").Alias("get") + c.Globals = globals + + // Required flags + c.CmdClause.Flag("id", "ID of the Dashboard to describe").Required().StringVar(&c.dashboardID) + + // Optional flags + c.RegisterFlagBool(c.JSONFlag()) + return &c +} + +// DescribeCommand calls the Fastly API to describe an appropriate resource. +type DescribeCommand struct { + argparser.Base + argparser.JSONOutput + + dashboardID string +} + +// Exec invokes the application logic for the command. +func (c *DescribeCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + input := c.constructInput() + dashboard, err := c.Globals.APIClient.GetObservabilityCustomDashboard(input) + if err != nil { + return err + } + + if ok, err := c.WriteJSON(out, dashboard); ok { + return err + } + + common.PrintDashboard(out, 0, dashboard) + return nil +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *DescribeCommand) constructInput() *fastly.GetObservabilityCustomDashboardInput { + var input fastly.GetObservabilityCustomDashboardInput + + input.ID = &c.dashboardID + + return &input +} diff --git a/pkg/commands/dashboard/doc.go b/pkg/commands/dashboard/doc.go new file mode 100644 index 000000000..fc73ada67 --- /dev/null +++ b/pkg/commands/dashboard/doc.go @@ -0,0 +1,2 @@ +// Package dashboard contains commands to manage custom Observability Dashboards. +package dashboard diff --git a/pkg/commands/dashboard/item/common.go b/pkg/commands/dashboard/item/common.go new file mode 100644 index 000000000..6089afd81 --- /dev/null +++ b/pkg/commands/dashboard/item/common.go @@ -0,0 +1,9 @@ +package item + +var ( + sourceTypes = []string{"stats.domain", "stats.edge", "stats.origin"} + visualizationTypes = []string{"chart"} + plotTypes = []string{"bar", "donut", "line", "single-metric"} + calculationMethods = []string{"avg", "sum", "min", "max", "latest", "p95"} + formats = []string{"number", "bytes", "percent", "requests", "responses", "seconds", "milliseconds", "ratio", "bitrate"} +) diff --git a/pkg/commands/dashboard/item/create.go b/pkg/commands/dashboard/item/create.go new file mode 100644 index 000000000..d4c0204e6 --- /dev/null +++ b/pkg/commands/dashboard/item/create.go @@ -0,0 +1,121 @@ +package item + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/dashboard/common" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// NewCreateCommand returns a usable command registered under the parent. +func NewCreateCommand(parent argparser.Registerer, globals *global.Data) *CreateCommand { + var c CreateCommand + c.CmdClause = parent.Command("create", "Create a custom dashboard item").Alias("add") + c.Globals = globals + + // Required flags + c.CmdClause.Flag("dashboard-id", "ID of the Dashboard to contain the item").Required().StringVar(&c.dashboardID) + c.CmdClause.Flag("title", "A human-readable title for the dashboard item").Required().StringVar(&c.title) + c.CmdClause.Flag("subtitle", "A human-readable subtitle for the dashboard item. Often a description of the visualization").Required().StringVar(&c.subtitle) + c.CmdClause.Flag("source-type", "The source of the data to display").Required().HintOptions(sourceTypes...).EnumVar(&c.sourceType, sourceTypes...) + c.CmdClause.Flag("metric", "The metrics to visualize. Valid options depend on the selected data source. Set flag multiple times to include multiple metrics").Required().StringsVar(&c.metrics) + c.CmdClause.Flag("plot-type", "The type of chart to display").Required().HintOptions(plotTypes...).EnumVar(&c.plotType, plotTypes...) + + // Optional flags + c.RegisterFlagBool(c.JSONFlag()) // --json + c.CmdClause.Flag("visualization-type", `The type of visualization to display. Currently, only "chart" is supported`).Default("chart").HintOptions(visualizationTypes...).EnumVar(&c.vizType, visualizationTypes...) + c.CmdClause.Flag("calculation-method", "The aggregation function to apply to the dataset").Action(c.calculationMethod.Set).HintOptions(calculationMethods...).EnumVar(&c.calculationMethod.Value, calculationMethods...) // --calculation-method + c.CmdClause.Flag("format", "The units to use to format the data").Action(c.format.Set).HintOptions(formats...).EnumVar(&c.format.Value, formats...) // --format + c.CmdClause.Flag("span", `The number of columns for the dashboard item to span. Dashboards are rendered on a 12-column grid on "desktop" screen sizes`).Default("4").Uint8Var(&c.span) + + return &c +} + +// CreateCommand calls the Fastly API to create an appropriate resource. +type CreateCommand struct { + argparser.Base + argparser.JSONOutput + + // required + dashboardID string + title string + subtitle string + sourceType string + metrics []string + plotType string + + // optional + vizType string + calculationMethod argparser.OptionalString + format argparser.OptionalString + span uint8 +} + +// Exec invokes the application logic for the command. +func (c *CreateCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + d, err := c.Globals.APIClient.GetObservabilityCustomDashboard(&fastly.GetObservabilityCustomDashboardInput{ID: &c.dashboardID}) + if err != nil { + return err + } + + input := c.constructInput(d) + d, err = c.Globals.APIClient.UpdateObservabilityCustomDashboard(input) + if err != nil { + return err + } + + if ok, err := c.WriteJSON(out, d); ok { + return err + } + + text.Success(out, `Added item to Custom Dashboard "%s" (id: %s)`, d.Name, d.ID) + // Summary isn't useful for a single dashboard, so print verbose by default + common.PrintDashboard(out, 0, d) + return nil +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *CreateCommand) constructInput(d *fastly.ObservabilityCustomDashboard) *fastly.UpdateObservabilityCustomDashboardInput { + input := fastly.UpdateObservabilityCustomDashboardInput{ + ID: &d.ID, + Name: &d.Name, + Description: &d.Description, + Items: &d.Items, + } + item := fastly.DashboardItem{ + Title: c.title, + Subtitle: c.subtitle, + Span: c.span, + DataSource: fastly.DashboardDataSource{ + Type: fastly.DashboardSourceType(c.sourceType), + Config: fastly.DashboardSourceConfig{ + Metrics: c.metrics, + }, + }, + Visualization: fastly.DashboardVisualization{ + Type: fastly.VisualizationType(c.vizType), + Config: fastly.VisualizationConfig{ + PlotType: fastly.PlotType(c.plotType), + }, + }, + } + if c.calculationMethod.WasSet { + item.Visualization.Config.CalculationMethod = fastly.ToPointer(fastly.CalculationMethod(c.calculationMethod.Value)) + } + if c.format.WasSet { + item.Visualization.Config.Format = fastly.ToPointer(fastly.VisualizationFormat(c.format.Value)) + } + + *input.Items = append(*input.Items, item) + + return &input +} diff --git a/pkg/commands/dashboard/item/delete.go b/pkg/commands/dashboard/item/delete.go new file mode 100644 index 000000000..188400b38 --- /dev/null +++ b/pkg/commands/dashboard/item/delete.go @@ -0,0 +1,103 @@ +package item + +import ( + "io" + "slices" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// NewDeleteCommand returns a usable command registered under the parent. +func NewDeleteCommand(parent argparser.Registerer, globals *global.Data) *DeleteCommand { + var c DeleteCommand + c.CmdClause = parent.Command("delete", "Delete a custom dashboard item").Alias("add") + c.Globals = globals + + // Required flags + c.CmdClause.Flag("dashboard-id", "ID of the Dashboard containing the item").Required().StringVar(&c.dashboardID) // --dashboard-id + c.CmdClause.Flag("item-id", "ID of the Item to be deleted").Required().StringVar(&c.itemID) // --item-id + + // Optional flags + c.RegisterFlagBool(c.JSONFlag()) // --json + + return &c +} + +// DeleteCommand calls the Fastly API to delete an appropriate resource. +type DeleteCommand struct { + argparser.Base + argparser.JSONOutput + + // required + dashboardID string + itemID string +} + +// Exec invokes the application logic for the command. +func (c *DeleteCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + d, err := c.Globals.APIClient.GetObservabilityCustomDashboard(&fastly.GetObservabilityCustomDashboardInput{ID: &c.dashboardID}) + if err != nil { + return err + } + + success := false + numItems := len(d.Items) + + if slices.ContainsFunc(d.Items, func(di fastly.DashboardItem) bool { + return di.ID == c.itemID + }) { + input := c.constructInput(d) + d, err = c.Globals.APIClient.UpdateObservabilityCustomDashboard(input) + if err != nil { + return err + } + + success = true + } + + if c.JSONOutput.Enabled { + o := struct { + ID string `json:"item_id"` + Deleted bool `json:"deleted"` + NewState *fastly.ObservabilityCustomDashboard `json:"dashboard_state"` + }{ + c.itemID, + success, + d, + } + _, err := c.WriteJSON(out, o) + return err + } + + if success { + text.Success(out, `Removed %d dashboard item(s) from Custom Dashboard "%s" (dashboardID: %s)`, (numItems - (len(d.Items))), d.Name, d.ID) + } else { + text.Warning(out, "dashboard (%s) has no item with ID (%s)", d.ID, c.itemID) + } + return nil +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *DeleteCommand) constructInput(d *fastly.ObservabilityCustomDashboard) *fastly.UpdateObservabilityCustomDashboardInput { + input := fastly.UpdateObservabilityCustomDashboardInput{ + ID: &d.ID, + Name: &d.Name, + Description: &d.Description, + } + + items := slices.DeleteFunc(d.Items, func(di fastly.DashboardItem) bool { + return di.ID == c.itemID + }) + input.Items = &items + + return &input +} diff --git a/pkg/commands/dashboard/item/describe.go b/pkg/commands/dashboard/item/describe.go new file mode 100644 index 000000000..1eea817c8 --- /dev/null +++ b/pkg/commands/dashboard/item/describe.go @@ -0,0 +1,83 @@ +package item + +import ( + "fmt" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/dashboard/common" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" +) + +// NewDescribeCommand returns a usable command registered under the parent. +func NewDescribeCommand(parent argparser.Registerer, globals *global.Data) *DescribeCommand { + var c DescribeCommand + c.CmdClause = parent.Command("describe", "Describe a custom dashboard item").Alias("add") + c.Globals = globals + + // Required flags + c.CmdClause.Flag("dashboard-id", "ID of the Dashboard containing the item").Required().StringVar(&c.dashboardID) // --dashboard-id + c.CmdClause.Flag("item-id", "ID of the Item to be described").Required().StringVar(&c.itemID) // --item-id + + // Optional flags + c.RegisterFlagBool(c.JSONFlag()) // --json + + return &c +} + +// DescribeCommand calls the Fastly API to describe an appropriate resource. +type DescribeCommand struct { + argparser.Base + argparser.JSONOutput + + // required + dashboardID string + itemID string +} + +// Exec invokes the application logic for the command. +func (c *DescribeCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + input := c.constructInput() + + d, err := c.Globals.APIClient.GetObservabilityCustomDashboard(input) + if err != nil { + return err + } + + di, err := getItemFromDashboard(d, c.itemID) + if err != nil { + return err + } + + if c.JSONOutput.Enabled { + _, err := c.WriteJSON(out, di) + if err != nil { + return err + } + } else { + common.PrintItem(out, 0, di) + } + + return nil +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *DescribeCommand) constructInput() *fastly.GetObservabilityCustomDashboardInput { + return &fastly.GetObservabilityCustomDashboardInput{ID: &c.dashboardID} +} + +func getItemFromDashboard(d *fastly.ObservabilityCustomDashboard, itemID string) (*fastly.DashboardItem, error) { + for _, di := range d.Items { + if di.ID == itemID { + return &di, nil + } + } + return nil, fmt.Errorf("could not find item with ID (%s) in Dashboard (%s)", itemID, d.ID) +} diff --git a/pkg/commands/dashboard/item/doc.go b/pkg/commands/dashboard/item/doc.go new file mode 100644 index 000000000..1dd5d5a8e --- /dev/null +++ b/pkg/commands/dashboard/item/doc.go @@ -0,0 +1,3 @@ +// Package item contains commands to inspect and manipulate the contents of +// a Custom Observability Dashboard. +package item diff --git a/pkg/commands/dashboard/item/item_test.go b/pkg/commands/dashboard/item/item_test.go new file mode 100644 index 000000000..746a970a0 --- /dev/null +++ b/pkg/commands/dashboard/item/item_test.go @@ -0,0 +1,333 @@ +package item_test + +import ( + "fmt" + "testing" + + "github.com/fastly/go-fastly/v10/fastly" + + root "github.com/fastly/cli/pkg/commands/dashboard" + sub "github.com/fastly/cli/pkg/commands/dashboard/item" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" +) + +var ( + testDate = testutil.Date + userID = "test-user" + dashboardID = "beepboop" + itemID = "bleepblorp" + dashboardName = "Foo" + dashboardDescription = "Testing..." + title = "Title" + subtitle = "Subtitle" + sourceType = "stats.edge" + metrics = "requests" + plotType = "line" + vizType = "chart" + calculationMethod = "latest" + format = "requests" + span = 8 + defaultItem = fastly.DashboardItem{ + DataSource: fastly.DashboardDataSource{ + Config: fastly.DashboardSourceConfig{ + Metrics: []string{metrics}, + }, + Type: fastly.DashboardSourceType(sourceType), + }, + ID: itemID, + Span: uint8(span), + Subtitle: subtitle, + Title: title, + Visualization: fastly.DashboardVisualization{ + Config: fastly.VisualizationConfig{ + CalculationMethod: fastly.ToPointer(fastly.CalculationMethod(calculationMethod)), + Format: fastly.ToPointer(fastly.VisualizationFormat(format)), + PlotType: fastly.PlotType(plotType), + }, + Type: fastly.VisualizationType(vizType), + }, + } + defaultDashboard = func() fastly.ObservabilityCustomDashboard { + return fastly.ObservabilityCustomDashboard{ + CreatedAt: testDate, + CreatedBy: userID, + Description: dashboardDescription, + ID: dashboardID, + Items: []fastly.DashboardItem{defaultItem}, + Name: dashboardName, + UpdatedAt: testDate, + UpdatedBy: userID, + } + } +) + +func TestCreate(t *testing.T) { + allRequiredFlags := fmt.Sprintf("--dashboard-id %s --title %s --subtitle %s --source-type %s --metric %s --plot-type %s", dashboardID, title, subtitle, sourceType, metrics, plotType) + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --dashboard-id flag", + Args: fmt.Sprintf("--title %s --subtitle %s --source-type %s --metric %s --plot-type %s", title, subtitle, sourceType, metrics, plotType), + WantError: "error parsing arguments: required flag --dashboard-id not provided", + }, + { + Name: "validate missing --title flag", + Args: fmt.Sprintf("--dashboard-id %s --subtitle %s --source-type %s --metric %s --plot-type %s", dashboardID, subtitle, sourceType, metrics, plotType), + WantError: "error parsing arguments: required flag --title not provided", + }, + { + Name: "validate missing --subtitle flag", + Args: fmt.Sprintf("--dashboard-id %s --title %s --source-type %s --metric %s --plot-type %s", dashboardID, title, sourceType, metrics, plotType), + WantError: "error parsing arguments: required flag --subtitle not provided", + }, + { + Name: "validate missing --source-type flag", + Args: fmt.Sprintf("--dashboard-id %s --title %s --subtitle %s --metric %s --plot-type %s", dashboardID, title, subtitle, metrics, plotType), + WantError: "error parsing arguments: required flag --source-type not provided", + }, + { + Name: "validate missing --metric flag", + Args: fmt.Sprintf("--dashboard-id %s --title %s --subtitle %s --source-type %s --plot-type %s", dashboardID, title, subtitle, sourceType, plotType), + WantError: "error parsing arguments: required flag --metric not provided", + }, + { + Name: "validate missing --plot-type flag", + Args: fmt.Sprintf("--dashboard-id %s --title %s --subtitle %s --source-type %s --metric %s", dashboardID, title, subtitle, sourceType, metrics), + WantError: "error parsing arguments: required flag --plot-type not provided", + }, + { + Name: "validate multiple --metric flag", + API: mock.API{ + GetObservabilityCustomDashboardFn: getDashboardOK, + UpdateObservabilityCustomDashboardFn: updateDashboardOK, + }, + Args: allRequiredFlags + " --metric responses", + WantOutput: "Metrics: requests, responses", + }, + { + Name: "validate all required flags", + API: mock.API{ + GetObservabilityCustomDashboardFn: getDashboardOK, + UpdateObservabilityCustomDashboardFn: updateDashboardOK, + }, + Args: allRequiredFlags, + WantOutput: `Added item to Custom Dashboard "Foo"`, + }, + { + Name: "validate all optional flags", + API: mock.API{ + GetObservabilityCustomDashboardFn: getDashboardOK, + UpdateObservabilityCustomDashboardFn: updateDashboardOK, + }, + Args: fmt.Sprintf("%s --visualization-type %s --calculation-method %s --format %s --span %d", allRequiredFlags, vizType, calculationMethod, format, span), + WantOutput: `Added item to Custom Dashboard "Foo"`, + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "create"}, scenarios) +} + +func TestDelete(t *testing.T) { + allRequiredFlags := fmt.Sprintf("--dashboard-id %s --item-id %s", dashboardID, itemID) + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --dashboard-id flag", + Args: fmt.Sprintf("--item-id %s", itemID), + WantError: "error parsing arguments: required flag --dashboard-id not provided", + }, + { + Name: "validate missing --item-id flag", + Args: fmt.Sprintf("--dashboard-id %s", dashboardID), + WantError: "error parsing arguments: required flag --item-id not provided", + }, + { + Name: "validate all required flags", + API: mock.API{ + GetObservabilityCustomDashboardFn: getDashboardOK, + UpdateObservabilityCustomDashboardFn: updateDashboardEmpty, + }, + Args: allRequiredFlags, + WantOutput: `Removed 1 dashboard item(s) from Custom Dashboard "Foo"`, + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "delete"}, scenarios) +} + +func TestDescribe(t *testing.T) { + allRequiredFlags := fmt.Sprintf("--dashboard-id %s --item-id %s", dashboardID, itemID) + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --dashboard-id flag", + Args: fmt.Sprintf("--item-id %s", itemID), + WantError: "error parsing arguments: required flag --dashboard-id not provided", + }, + { + Name: "validate missing --item-id flag", + Args: fmt.Sprintf("--dashboard-id %s", dashboardID), + WantError: "error parsing arguments: required flag --item-id not provided", + }, + { + Name: "validate all required flags", + API: mock.API{ + GetObservabilityCustomDashboardFn: getDashboardOK, + UpdateObservabilityCustomDashboardFn: updateDashboardEmpty, + }, + Args: allRequiredFlags, + WantOutput: "ID: bleepblorp\nTitle: Title\nSubtitle: Subtitle\nSpan: 8\nData Source:\n Type: stats.edge\n Metrics: requests\nVisualization:\n Type: chart\n Plot Type: line\n Calculation Method: latest\n Format: requests\n", + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "describe"}, scenarios) +} + +func TestUpdate(t *testing.T) { + allRequiredFlags := fmt.Sprintf("--dashboard-id %s --item-id %s --json", dashboardID, itemID) + + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --dashboard-id flag", + Args: fmt.Sprintf("--item-id %s", itemID), + WantError: "error parsing arguments: required flag --dashboard-id not provided", + }, + { + Name: "validate missing --item-id flag", + Args: fmt.Sprintf("--dashboard-id %s", dashboardID), + WantError: "error parsing arguments: required flag --item-id not provided", + }, + { + Name: "validate all required flags", + API: mock.API{ + GetObservabilityCustomDashboardFn: getDashboardOK, + UpdateObservabilityCustomDashboardFn: updateDashboardOK, + }, + Args: allRequiredFlags, + WantOutputs: []string{ + `"name":`, "Foo", + `"description":`, "Testing...", + `"items":`, + `"id":`, "bleepblorp", + `"title":`, "Title", + `"subtitle":`, "Subtitle", + `"span":`, "8", + `"data_source":`, + `"type":`, "stats.edge", + `"metrics":`, "requests", + `"visualization":`, + `"type":`, "chart", + `"plot_type":`, "line", + `"calculation_method":`, "latest", + `"format":`, "requests", + `"created_at":`, "2021-06-15T23:00:00Z", + `"updated_at":`, "2021-06-15T23:00:00Z", + `"created_by":`, "test-user", + `"updated_by":`, "test-user", + }, + }, + { + Name: "validate optional --title flag", + API: mock.API{ + GetObservabilityCustomDashboardFn: getDashboardOK, + UpdateObservabilityCustomDashboardFn: updateDashboardOK, + }, + Args: fmt.Sprintf("%s --title %s", allRequiredFlags, "NewTitle"), + WantOutput: `"title": "NewTitle"`, + }, + { + Name: "validate optional --subtitle flag", + API: mock.API{ + GetObservabilityCustomDashboardFn: getDashboardOK, + UpdateObservabilityCustomDashboardFn: updateDashboardOK, + }, + Args: fmt.Sprintf("%s --subtitle %s", allRequiredFlags, "NewSubtitle"), + WantOutput: `"subtitle": "NewSubtitle"`, + }, + { + Name: "validate optional --span flag", + API: mock.API{ + GetObservabilityCustomDashboardFn: getDashboardOK, + UpdateObservabilityCustomDashboardFn: updateDashboardOK, + }, + Args: fmt.Sprintf("%s --span %d", allRequiredFlags, 12), + WantOutput: `"span": 12`, + }, + { + Name: "validate optional --source-type flag", + API: mock.API{ + GetObservabilityCustomDashboardFn: getDashboardOK, + UpdateObservabilityCustomDashboardFn: updateDashboardOK, + }, + Args: fmt.Sprintf("%s --source-type %s", allRequiredFlags, "stats.domain"), + WantOutput: `"type": "stats.domain"`, + }, + { + Name: "validate optional --metric flag", + API: mock.API{ + GetObservabilityCustomDashboardFn: getDashboardOK, + UpdateObservabilityCustomDashboardFn: updateDashboardOK, + }, + Args: fmt.Sprintf("%s --metric %s", allRequiredFlags, "status_4xx"), + WantOutputs: []string{"metrics", "status_4xx"}, + }, + { + Name: "validate multiple --metric flag", + API: mock.API{ + GetObservabilityCustomDashboardFn: getDashboardOK, + UpdateObservabilityCustomDashboardFn: updateDashboardOK, + }, + Args: fmt.Sprintf("%s --metric %s --metric %s --metric %s", allRequiredFlags, "status_2xx", "status_4xx", "status_5xx"), + WantOutputs: []string{ + "metrics", + "status_2xx", + "status_4xx", + "status_5xx", + }, + }, + { + Name: "validate optional --calculation-method flag", + API: mock.API{ + GetObservabilityCustomDashboardFn: getDashboardOK, + UpdateObservabilityCustomDashboardFn: updateDashboardOK, + }, + Args: fmt.Sprintf("%s --calculation-method %s", allRequiredFlags, "avg"), + WantOutput: `"calculation_method": "avg"`, + }, + { + Name: "validate optional --format flag", + API: mock.API{ + GetObservabilityCustomDashboardFn: getDashboardOK, + UpdateObservabilityCustomDashboardFn: updateDashboardOK, + }, + Args: fmt.Sprintf("%s --format %s", allRequiredFlags, "ratio"), + WantOutput: `"format": "ratio"`, + }, + { + Name: "validate optional --plot-type flag", + API: mock.API{ + GetObservabilityCustomDashboardFn: getDashboardOK, + UpdateObservabilityCustomDashboardFn: updateDashboardOK, + }, + Args: fmt.Sprintf("%s --plot-type %s", allRequiredFlags, "single-metric"), + WantOutput: `"plot_type": "single-metric"`, + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "update"}, scenarios) +} + +func getDashboardOK(_ *fastly.GetObservabilityCustomDashboardInput) (*fastly.ObservabilityCustomDashboard, error) { + d := defaultDashboard() + return &d, nil +} + +func updateDashboardOK(i *fastly.UpdateObservabilityCustomDashboardInput) (*fastly.ObservabilityCustomDashboard, error) { + d := defaultDashboard() + d.Items = *i.Items + return &d, nil +} + +func updateDashboardEmpty(_ *fastly.UpdateObservabilityCustomDashboardInput) (*fastly.ObservabilityCustomDashboard, error) { + d := defaultDashboard() + d.Items = []fastly.DashboardItem{} + return &d, nil +} diff --git a/pkg/commands/dashboard/item/root.go b/pkg/commands/dashboard/item/root.go new file mode 100644 index 000000000..5b3c0ad20 --- /dev/null +++ b/pkg/commands/dashboard/item/root.go @@ -0,0 +1,31 @@ +package item + +import ( + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" +) + +// RootCommand is the parent command for all subcommands in this package. +// It should be installed under the primary root command. +type RootCommand struct { + argparser.Base + // no flags +} + +// CommandName is the string to be used to invoke this command. +const CommandName = "item" + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent argparser.Registerer, globals *global.Data) *RootCommand { + var c RootCommand + c.Globals = globals + c.CmdClause = parent.Command(CommandName, "Manipulate Fastly Custom Dashboard Items") + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { + panic("unreachable") +} diff --git a/pkg/commands/dashboard/item/update.go b/pkg/commands/dashboard/item/update.go new file mode 100644 index 000000000..9364d0da8 --- /dev/null +++ b/pkg/commands/dashboard/item/update.go @@ -0,0 +1,136 @@ +package item + +import ( + "fmt" + "io" + "slices" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" +) + +// NewUpdateCommand returns a usable command registered under the parent. +func NewUpdateCommand(parent argparser.Registerer, globals *global.Data) *UpdateCommand { + var c UpdateCommand + c.CmdClause = parent.Command("update", "Update a custom dashboard item").Alias("add") + c.Globals = globals + + // Required flags + c.CmdClause.Flag("dashboard-id", "ID of the Dashboard containing the item").Required().StringVar(&c.dashboardID) // --dashboard-id + c.CmdClause.Flag("item-id", "ID of the Item to be updated").Required().StringVar(&c.itemID) // --item-id + + // Optional flags + c.RegisterFlagBool(c.JSONFlag()) // --json + c.CmdClause.Flag("title", "A human-readable title for the dashboard item").Action(c.title.Set).StringVar(&c.title.Value) // --title + c.CmdClause.Flag("subtitle", "A human-readable subtitle for the dashboard item. Often a description of the visualization").Action(c.subtitle.Set).StringVar(&c.subtitle.Value) // --subtitle + c.CmdClause.Flag("span", `The number of columns for the dashboard item to span. Dashboards are rendered on a 12-column grid on "desktop" screen sizes`).Action(c.span.Set).IntVar(&c.span.Value) // --span + c.CmdClause.Flag("source-type", "The source of the data to display").Action(c.sourceType.Set).HintOptions(sourceTypes...).EnumVar(&c.sourceType.Value, sourceTypes...) // --source-type + c.CmdClause.Flag("metric", "The metrics to visualize. Valid options depend on the selected data source. Set flag multiple times to include multiple metrics").Action(c.metrics.Set).StringsVar(&c.metrics.Value) // --metrics + c.CmdClause.Flag("visualization-type", `The type of visualization to display. Currently, only "chart" is supported`).Action(c.vizType.Set).HintOptions(visualizationTypes...).EnumVar(&c.vizType.Value, visualizationTypes...) // --visualization-type + c.CmdClause.Flag("calculation-method", "The aggregation function to apply to the dataset").Action(c.calculationMethod.Set).HintOptions(calculationMethods...).EnumVar(&c.calculationMethod.Value, calculationMethods...) // --calculation-method + c.CmdClause.Flag("format", "The units to use to format the data").Action(c.format.Set).HintOptions(formats...).EnumVar(&c.format.Value, formats...) // --format + c.CmdClause.Flag("plot-type", "The type of chart to display").Action(c.plotType.Set).HintOptions(plotTypes...).EnumVar(&c.plotType.Value, plotTypes...) // --plot-type + + return &c +} + +// UpdateCommand calls the Fastly API to update an appropriate resource. +type UpdateCommand struct { + argparser.Base + argparser.JSONOutput + + // required + dashboardID string + itemID string + + // optional + title argparser.OptionalString + subtitle argparser.OptionalString + span argparser.OptionalInt + sourceType argparser.OptionalString + metrics argparser.OptionalStringSlice + plotType argparser.OptionalString + vizType argparser.OptionalString + calculationMethod argparser.OptionalString + format argparser.OptionalString +} + +// Exec invokes the application logic for the command. +func (c *UpdateCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + d, err := c.Globals.APIClient.GetObservabilityCustomDashboard(&fastly.GetObservabilityCustomDashboardInput{ID: &c.dashboardID}) + if err != nil { + return err + } + + input, err := c.constructInput(d) + if err != nil { + return err + } + + d, err = c.Globals.APIClient.UpdateObservabilityCustomDashboard(input) + if err != nil { + return err + } + + if ok, err := c.WriteJSON(out, d); ok { + return err + } + + return nil +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *UpdateCommand) constructInput(d *fastly.ObservabilityCustomDashboard) (*fastly.UpdateObservabilityCustomDashboardInput, error) { + var input fastly.UpdateObservabilityCustomDashboardInput + + input.ID = &d.ID + input.Items = &d.Items + idx := slices.IndexFunc(*input.Items, func(di fastly.DashboardItem) bool { + return di.ID == c.itemID + }) + if idx < 0 { + return nil, fmt.Errorf("dashboard (%s) does not contain item with ID %s", d.ID, c.itemID) + } + item := &(*input.Items)[idx] + + if c.title.WasSet { + item.Title = c.title.Value + } + if c.subtitle.WasSet { + item.Subtitle = c.subtitle.Value + } + if c.span.WasSet { + if span := c.span.Value; span <= 255 && span >= 0 { + item.Span = uint8(span) + } else { + return nil, fmt.Errorf("invalid span value %d", span) + } + } + if c.sourceType.WasSet { + item.DataSource.Type = fastly.DashboardSourceType(c.sourceType.Value) + } + if c.metrics.WasSet { + item.DataSource.Config.Metrics = c.metrics.Value + } + if c.vizType.WasSet { + item.Visualization.Type = fastly.VisualizationType(c.vizType.Value) + } + if c.plotType.WasSet { + item.Visualization.Config.PlotType = fastly.PlotType(c.plotType.Value) + } + if c.calculationMethod.WasSet { + item.Visualization.Config.CalculationMethod = fastly.ToPointer(fastly.CalculationMethod(c.calculationMethod.Value)) + } + if c.format.WasSet { + item.Visualization.Config.Format = fastly.ToPointer(fastly.VisualizationFormat(c.format.Value)) + } + + return &input, nil +} diff --git a/pkg/commands/dashboard/list.go b/pkg/commands/dashboard/list.go new file mode 100644 index 000000000..dad769243 --- /dev/null +++ b/pkg/commands/dashboard/list.go @@ -0,0 +1,141 @@ +package dashboard + +import ( + "errors" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/dashboard/common" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// NewListCommand returns a usable command registered under the parent. +func NewListCommand(parent argparser.Registerer, globals *global.Data) *ListCommand { + var c ListCommand + c.CmdClause = parent.Command("list", "List custom dashboards") + c.Globals = globals + + // Optional Flags + c.RegisterFlagBool(c.JSONFlag()) // --json + c.CmdClause.Flag("cursor", "Pagination cursor (Use 'next_cursor' value from list output)").Action(c.cursor.Set).StringVar(&c.cursor.Value) + c.CmdClause.Flag("limit", "Maximum number of items to list").Action(c.limit.Set).IntVar(&c.limit.Value) + c.CmdClause.Flag("order", "Sort by one of the following [asc, desc]").Action(c.order.Set).StringVar(&c.order.Value) + c.CmdClause.Flag("sort", "Sort by one of the following [name, created_at, updated_at]").Action(c.sort.Set).StringVar(&c.sort.Value) + + return &c +} + +// ListCommand calls the Fastly API to list appropriate resources. +type ListCommand struct { + argparser.Base + argparser.JSONOutput + + cursor argparser.OptionalString + limit argparser.OptionalInt + sort argparser.OptionalString + order argparser.OptionalString +} + +// Exec invokes the application logic for the command. +func (c *ListCommand) Exec(in io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + input, err := c.constructInput() + if err != nil { + return err + } + + var dashboards []fastly.ObservabilityCustomDashboard + loadAllPages := c.JSONOutput.Enabled || c.Globals.Flags.NonInteractive || c.Globals.Flags.AutoYes + + for { + o, err := c.Globals.APIClient.ListObservabilityCustomDashboards(input) + if err != nil { + return err + } + + if o != nil { + dashboards = append(dashboards, o.Data...) + + if loadAllPages { + if o.Meta.NextCursor != "" { + input.Cursor = &o.Meta.NextCursor + continue + } + break + } + + if c.Globals.Verbose() { + common.PrintVerbose(out, dashboards) + } else { + common.PrintSummary(out, dashboards) + } + + if o.Meta.NextCursor != "" && text.IsTTY(out) { + text.Break(out) + printNextPage, err := text.AskYesNo(out, "Print next page [y/N]: ", in) + if err != nil { + return err + } + if printNextPage { + dashboards = []fastly.ObservabilityCustomDashboard{} + input.Cursor = &o.Meta.NextCursor + continue + } + } + } + + return nil + } + + if ok, err := c.WriteJSON(out, dashboards); ok { + // No pagination prompt w/ JSON output. + return err + } + + // Only print output here if we've not already printed JSON. + if c.Globals.Verbose() { + common.PrintVerbose(out, dashboards) + } else { + common.PrintSummary(out, dashboards) + } + + return nil +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *ListCommand) constructInput() (*fastly.ListObservabilityCustomDashboardsInput, error) { + var input fastly.ListObservabilityCustomDashboardsInput + + if c.cursor.WasSet { + input.Cursor = &c.cursor.Value + } + if c.limit.WasSet { + input.Limit = &c.limit.Value + } + var sign string + if c.order.WasSet { + switch c.order.Value { + case "asc": + case "desc": + sign = "-" + default: + err := errors.New("'order' flag must be one of the following [asc, desc]") + c.Globals.ErrLog.Add(err) + return nil, err + } + } + + if c.sort.WasSet { + str := sign + c.sort.Value + input.Sort = &str + } + + return &input, nil +} diff --git a/pkg/commands/dashboard/root.go b/pkg/commands/dashboard/root.go new file mode 100644 index 000000000..b46857186 --- /dev/null +++ b/pkg/commands/dashboard/root.go @@ -0,0 +1,31 @@ +package dashboard + +import ( + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" +) + +// RootCommand is the parent command for all subcommands in this package. +// It should be installed under the primary root command. +type RootCommand struct { + argparser.Base + // no flags +} + +// CommandName is the string to be used to invoke this command. +const CommandName = "dashboard" + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent argparser.Registerer, globals *global.Data) *RootCommand { + var c RootCommand + c.Globals = globals + c.CmdClause = parent.Command(CommandName, "Manipulate Fastly Custom Dashboards") + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { + panic("unreachable") +} diff --git a/pkg/commands/dashboard/update.go b/pkg/commands/dashboard/update.go new file mode 100644 index 000000000..c26b10371 --- /dev/null +++ b/pkg/commands/dashboard/update.go @@ -0,0 +1,75 @@ +package dashboard + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// NewUpdateCommand returns a usable command registered under the parent. +func NewUpdateCommand(parent argparser.Registerer, globals *global.Data) *UpdateCommand { + var c UpdateCommand + c.CmdClause = parent.Command("update", "Update a custom dashboard") + c.Globals = globals + + // Required flags + c.CmdClause.Flag("id", "ID of the Dashboard to update").Required().StringVar(&c.dashboardID) + + // Optional flags + c.RegisterFlagBool(c.JSONFlag()) // --json + c.CmdClause.Flag("name", "A human-readable name for the dashboard").Short('n').Action(c.name.Set).StringVar(&c.name.Value) // --name + c.CmdClause.Flag("description", "A short description of the dashboard").Action(c.description.Set).StringVar(&c.description.Value) // --description + + return &c +} + +// UpdateCommand calls the Fastly API to update an appropriate resource. +type UpdateCommand struct { + argparser.Base + argparser.JSONOutput + + dashboardID string + name argparser.OptionalString + description argparser.OptionalString +} + +// Exec invokes the application logic for the command. +func (c *UpdateCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + input := c.constructInput() + dashboard, err := c.Globals.APIClient.UpdateObservabilityCustomDashboard(input) + if err != nil { + return err + } + + if ok, err := c.WriteJSON(out, dashboard); ok { + return err + } + + text.Success(out, `Updated Custom Dashboard "%s" (id: %s)`, dashboard.Name, dashboard.ID) + return nil +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *UpdateCommand) constructInput() *fastly.UpdateObservabilityCustomDashboardInput { + var input fastly.UpdateObservabilityCustomDashboardInput + + input.ID = &c.dashboardID + + if c.name.WasSet { + input.Name = &c.name.Value + } + if c.description.WasSet { + input.Description = &c.description.Value + } + + return &input +} diff --git a/pkg/commands/dictionary/create.go b/pkg/commands/dictionary/create.go new file mode 100644 index 000000000..7b493b4dd --- /dev/null +++ b/pkg/commands/dictionary/create.go @@ -0,0 +1,119 @@ +package dictionary + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// CreateCommand calls the Fastly API to create a service. +type CreateCommand struct { + argparser.Base + + // Required. + serviceVersion argparser.OptionalServiceVersion + + // Optional. + autoClone argparser.OptionalAutoClone + name argparser.OptionalString + serviceName argparser.OptionalServiceNameID + writeOnly argparser.OptionalBool +} + +// NewCreateCommand returns a usable command registered under the parent. +func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { + c := CreateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("create", "Create a Fastly edge dictionary on a Fastly service version") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.autoClone.Set, + Dst: &c.autoClone.Value, + }) + c.CmdClause.Flag("name", "Name of Dictionary").Short('n').Action(c.name.Set).StringVar(&c.name.Value) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + c.CmdClause.Flag("write-only", "Whether to mark this dictionary as write-only").Action(c.writeOnly.Set).BoolVar(&c.writeOnly.Value) + return &c +} + +// Exec invokes the application logic for the command. +func (c *CreateCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.autoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + serviceVersionNumber := fastly.ToValue(serviceVersion.Number) + + input := fastly.CreateDictionaryInput{ + ServiceID: serviceID, + ServiceVersion: serviceVersionNumber, + } + if c.name.WasSet { + input.Name = &c.name.Value + } + if c.writeOnly.WasSet { + input.WriteOnly = fastly.ToPointer(fastly.Compatibool(c.writeOnly.Value)) + } + + d, err := c.Globals.APIClient.CreateDictionary(&input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": serviceVersionNumber, + }) + return err + } + + var writeOnlyOutput string + if fastly.ToValue(d.WriteOnly) { + writeOnlyOutput = "as write-only " + } + + text.Success(out, "Created dictionary %s %s(id %s, service %s, version %d)", fastly.ToValue(d.Name), writeOnlyOutput, fastly.ToValue(d.DictionaryID), fastly.ToValue(d.ServiceID), fastly.ToValue(d.ServiceVersion)) + return nil +} diff --git a/pkg/commands/dictionary/delete.go b/pkg/commands/dictionary/delete.go new file mode 100644 index 000000000..48e1410d4 --- /dev/null +++ b/pkg/commands/dictionary/delete.go @@ -0,0 +1,98 @@ +package dictionary + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// DeleteCommand calls the Fastly API to delete a service. +type DeleteCommand struct { + argparser.Base + Input fastly.DeleteDictionaryInput + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion + autoClone argparser.OptionalAutoClone +} + +// NewDeleteCommand returns a usable command registered under the parent. +func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { + c := DeleteCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("delete", "Delete a Fastly edge dictionary from a Fastly service version") + + // Required. + c.CmdClause.Flag("name", "Name of Dictionary").Short('n').Required().StringVar(&c.Input.Name) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.autoClone.Set, + Dst: &c.autoClone.Value, + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + return &c +} + +// Exec invokes the application logic for the command. +func (c *DeleteCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.autoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + c.Input.ServiceID = serviceID + c.Input.ServiceVersion = fastly.ToValue(serviceVersion.Number) + + err = c.Globals.APIClient.DeleteDictionary(&c.Input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fastly.ToValue(serviceVersion.Number), + }) + return err + } + + text.Success(out, "Deleted dictionary %s (service %s version %d)", c.Input.Name, c.Input.ServiceID, c.Input.ServiceVersion) + return nil +} diff --git a/pkg/commands/dictionary/describe.go b/pkg/commands/dictionary/describe.go new file mode 100644 index 000000000..7a43c367f --- /dev/null +++ b/pkg/commands/dictionary/describe.go @@ -0,0 +1,163 @@ +package dictionary + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// DescribeCommand calls the Fastly API to describe a dictionary. +type DescribeCommand struct { + argparser.Base + argparser.JSONOutput + + Input fastly.GetDictionaryInput + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion +} + +// NewDescribeCommand returns a usable command registered under the parent. +func NewDescribeCommand(parent argparser.Registerer, g *global.Data) *DescribeCommand { + c := DescribeCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("describe", "Show detailed information about a Fastly edge dictionary").Alias("get") + + // Required. + c.CmdClause.Flag("name", "Name of Dictionary").Short('n').Required().StringVar(&c.Input.Name) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + return &c +} + +// Exec invokes the application logic for the command. +func (c *DescribeCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fsterr.ServiceVersion(serviceVersion), + }) + return err + } + + serviceVersionNumber := fastly.ToValue(serviceVersion.Number) + + c.Input.ServiceID = serviceID + c.Input.ServiceVersion = serviceVersionNumber + + dictionary, err := c.Globals.APIClient.GetDictionary(&c.Input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": serviceVersionNumber, + }) + return err + } + dictionaryID := fastly.ToValue(dictionary.DictionaryID) + + var ( + info *fastly.DictionaryInfo + items []*fastly.DictionaryItem + ) + + if c.Globals.Verbose() || c.JSONOutput.Enabled { + infoInput := fastly.GetDictionaryInfoInput{ + ServiceID: c.Input.ServiceID, + ServiceVersion: c.Input.ServiceVersion, + DictionaryID: dictionaryID, + } + info, err = c.Globals.APIClient.GetDictionaryInfo(&infoInput) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": serviceVersionNumber, + }) + return err + } + itemInput := fastly.ListDictionaryItemsInput{ + ServiceID: c.Input.ServiceID, + DictionaryID: dictionaryID, + } + items, err = c.Globals.APIClient.ListDictionaryItems(&itemInput) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": serviceVersionNumber, + }) + return err + } + } + + if c.JSONOutput.Enabled { + // NOTE: When not using JSON you have to provide the --verbose flag to get + // some extra information about the dictionary. When using --json we go + // ahead and acquire that info and combine it into the JSON output. + type container struct { + *fastly.Dictionary + *fastly.DictionaryInfo + Items []*fastly.DictionaryItem + } + + o := &container{Dictionary: dictionary, DictionaryInfo: info, Items: items} + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + } + + if !c.Globals.Verbose() { + text.Output(out, "Service ID: %s", fastly.ToValue(dictionary.ServiceID)) + } + text.Output(out, "Version: %d", fastly.ToValue(dictionary.ServiceVersion)) + text.PrintDictionary(out, "", dictionary) + + if c.Globals.Verbose() { + text.Output(out, "Digest: %s", fastly.ToValue(info.Digest)) + text.Output(out, "Item Count: %d", fastly.ToValue(info.ItemCount)) + + for i, item := range items { + text.Output(out, "Item %d/%d:", i+1, len(items)) + text.PrintDictionaryItemKV(out, " ", item) + } + } + + return nil +} diff --git a/pkg/commands/dictionary/dictionary_test.go b/pkg/commands/dictionary/dictionary_test.go new file mode 100644 index 000000000..96e9b364a --- /dev/null +++ b/pkg/commands/dictionary/dictionary_test.go @@ -0,0 +1,459 @@ +package dictionary_test + +import ( + "errors" + "strings" + "testing" + + "github.com/fastly/go-fastly/v10/fastly" + + root "github.com/fastly/cli/pkg/commands/dictionary" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" +) + +func TestDictionaryDescribe(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Args: "--version 1 --service-id 123", + WantError: "error parsing arguments: required flag --name not provided", + }, + { + Args: "--version 1 --service-id 123 --name dict-1", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + GetDictionaryFn: describeDictionaryOK, + }, + WantOutput: describeDictionaryOutput, + }, + { + Args: "--version 1 --service-id 123 --name dict-1", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + GetDictionaryFn: describeDictionaryOKDeleted, + }, + WantOutput: describeDictionaryOutputDeleted, + }, + { + Args: "--version 1 --service-id 123 --name dict-1 --verbose", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + GetDictionaryFn: describeDictionaryOK, + GetDictionaryInfoFn: getDictionaryInfoOK, + ListDictionaryItemsFn: listDictionaryItemsOK, + }, + WantOutput: describeDictionaryOutputVerbose, + }, + } + testutil.RunCLIScenarios(t, []string{root.CommandName, "describe"}, scenarios) +} + +func TestDictionaryCreate(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Args: "--version 1", + WantError: "error reading service: no service ID found", + }, + { + Args: "--version 1 --service-id 123 --name denylist --autoclone", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + CreateDictionaryFn: createDictionaryOK, + }, + WantOutput: createDictionaryOutput, + }, + { + Args: "--version 1 --service-id 123 --name denylist --write-only --autoclone", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + CreateDictionaryFn: createDictionaryOK, + }, + WantOutput: createDictionaryOutputWriteOnly, + }, + { + Args: "--version 1 --service-id 123 --name denylist --write-only fish --autoclone", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + }, + WantError: "error parsing arguments: unexpected 'fish'", + }, + { + Args: "--version 1 --service-id 123 --name denylist --autoclone", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + CreateDictionaryFn: createDictionaryDuplicate, + }, + WantError: "Duplicate record", + }, + } + testutil.RunCLIScenarios(t, []string{root.CommandName, "create"}, scenarios) +} + +func TestDeleteDictionary(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Args: "--service-id 123 --version 1", + WantError: "error parsing arguments: required flag --name not provided", + }, + { + Args: "--service-id 123 --version 1 --name allowlist --autoclone", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + DeleteDictionaryFn: deleteDictionaryOK, + }, + WantOutput: deleteDictionaryOutput, + }, + { + Args: "--service-id 123 --version 1 --name allowlist --autoclone", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + DeleteDictionaryFn: deleteDictionaryError, + }, + WantError: errTest.Error(), + }, + } + testutil.RunCLIScenarios(t, []string{root.CommandName, "delete"}, scenarios) +} + +func TestListDictionary(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Args: "--version 1", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListDictionariesFn: listDictionariesOk, + }, + WantError: "error reading service: no service ID found", + }, + { + Args: "--service-id 123", + WantError: "error parsing arguments: required flag --version not provided", + }, + { + Args: "--version 1 --service-id 123", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListDictionariesFn: listDictionariesOk, + }, + WantOutput: listDictionariesOutput, + }, + } + testutil.RunCLIScenarios(t, []string{root.CommandName, "list"}, scenarios) +} + +func TestUpdateDictionary(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Args: "--version 1 --name oldname --new-name newname", + WantError: "error reading service: no service ID found", + }, + { + Args: "--service-id 123 --name oldname --new-name newname", + WantError: "error parsing arguments: required flag --version not provided", + }, + { + Args: "--service-id 123 --version 1 --new-name newname", + WantError: "error parsing arguments: required flag --name not provided", + }, + { + Args: "--service-id 123 --version 1 --name oldname --autoclone", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + }, + WantError: "error parsing arguments: required flag --new-name or --write-only not provided", + }, + { + Args: "--service-id 123 --version 1 --name oldname --new-name dict-1 --autoclone", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + UpdateDictionaryFn: updateDictionaryNameOK, + }, + WantOutput: updateDictionaryNameOutput, + }, + { + Args: "--service-id 123 --version 1 --name oldname --new-name dict-1 --write-only true --autoclone", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + UpdateDictionaryFn: updateDictionaryNameOK, + }, + WantOutput: updateDictionaryNameOutput, + }, + { + Args: "--service-id 123 --version 1 --name oldname --write-only true --autoclone", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + UpdateDictionaryFn: updateDictionaryWriteOnlyOK, + }, + WantOutput: updateDictionaryOutput, + }, + { + Args: "-v --service-id 123 --version 1 --name oldname --new-name dict-1 --autoclone", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + UpdateDictionaryFn: updateDictionaryNameOK, + }, + WantOutput: updateDictionaryOutputVerbose, + }, + { + Args: "--service-id 123 --version 1 --name oldname --new-name dict-1 --autoclone", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + UpdateDictionaryFn: updateDictionaryError, + }, + WantError: errTest.Error(), + }, + } + testutil.RunCLIScenarios(t, []string{root.CommandName, "update"}, scenarios) +} + +func describeDictionaryOK(i *fastly.GetDictionaryInput) (*fastly.Dictionary, error) { + return &fastly.Dictionary{ + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer(i.Name), + CreatedAt: testutil.MustParseTimeRFC3339("2001-02-03T04:05:06Z"), + WriteOnly: fastly.ToPointer(false), + DictionaryID: fastly.ToPointer("456"), + UpdatedAt: testutil.MustParseTimeRFC3339("2001-02-03T04:05:07Z"), + }, nil +} + +func describeDictionaryOKDeleted(i *fastly.GetDictionaryInput) (*fastly.Dictionary, error) { + return &fastly.Dictionary{ + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer(i.Name), + CreatedAt: testutil.MustParseTimeRFC3339("2001-02-03T04:05:06Z"), + WriteOnly: fastly.ToPointer(false), + DictionaryID: fastly.ToPointer("456"), + UpdatedAt: testutil.MustParseTimeRFC3339("2001-02-03T04:05:07Z"), + DeletedAt: testutil.MustParseTimeRFC3339("2001-02-03T04:05:08Z"), + }, nil +} + +func createDictionaryOK(i *fastly.CreateDictionaryInput) (*fastly.Dictionary, error) { + if i.WriteOnly == nil { + i.WriteOnly = fastly.ToPointer(fastly.Compatibool(false)) + } + return &fastly.Dictionary{ + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: i.Name, + CreatedAt: testutil.MustParseTimeRFC3339("2001-02-03T04:05:06Z"), + WriteOnly: fastly.ToPointer(bool(fastly.ToValue(i.WriteOnly))), + DictionaryID: fastly.ToPointer("456"), + UpdatedAt: testutil.MustParseTimeRFC3339("2001-02-03T04:05:07Z"), + }, nil +} + +// getDictionaryInfoOK mocks the response from fastly.GetDictionaryInfo, which +// is not otherwise used in the fastly-cli and will need to be updated here if +// that call changes. This function requires i.ID to equal "456" to enforce the +// input to this call matches the response to GetDictionaryInfo in +// describeDictionaryOK. +func getDictionaryInfoOK(i *fastly.GetDictionaryInfoInput) (*fastly.DictionaryInfo, error) { + if i.DictionaryID == "456" { + return &fastly.DictionaryInfo{ + ItemCount: fastly.ToPointer(2), + LastUpdated: testutil.MustParseTimeRFC3339("2001-02-03T04:05:07Z"), + Digest: fastly.ToPointer("digest_hash"), + }, nil + } + return nil, errFail +} + +// listDictionaryItemsOK mocks the response from fastly.ListDictionaryItems +// which is primarily used in the fastly-cli.dictionaryitem package and will +// need to be updated here if that call changes. +func listDictionaryItemsOK(i *fastly.ListDictionaryItemsInput) ([]*fastly.DictionaryItem, error) { + return []*fastly.DictionaryItem{ + { + ServiceID: fastly.ToPointer(i.ServiceID), + DictionaryID: fastly.ToPointer(i.DictionaryID), + ItemKey: fastly.ToPointer("foo"), + ItemValue: fastly.ToPointer("bar"), + CreatedAt: testutil.MustParseTimeRFC3339("2001-02-03T04:05:06Z"), + UpdatedAt: testutil.MustParseTimeRFC3339("2001-02-03T04:05:07Z"), + }, + { + ServiceID: fastly.ToPointer(i.ServiceID), + DictionaryID: fastly.ToPointer(i.DictionaryID), + ItemKey: fastly.ToPointer("baz"), + ItemValue: fastly.ToPointer("bear"), + CreatedAt: testutil.MustParseTimeRFC3339("2001-02-03T04:05:06Z"), + UpdatedAt: testutil.MustParseTimeRFC3339("2001-02-03T04:05:07Z"), + DeletedAt: testutil.MustParseTimeRFC3339("2001-02-03T04:06:08Z"), + }, + }, nil +} + +func createDictionaryDuplicate(*fastly.CreateDictionaryInput) (*fastly.Dictionary, error) { + return nil, errors.New("Duplicate record") +} + +func deleteDictionaryOK(*fastly.DeleteDictionaryInput) error { + return nil +} + +func deleteDictionaryError(*fastly.DeleteDictionaryInput) error { + return errTest +} + +func listDictionariesOk(i *fastly.ListDictionariesInput) ([]*fastly.Dictionary, error) { + return []*fastly.Dictionary{ + { + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("dict-1"), + CreatedAt: testutil.MustParseTimeRFC3339("2001-02-03T04:05:06Z"), + WriteOnly: fastly.ToPointer(false), + DictionaryID: fastly.ToPointer("456"), + UpdatedAt: testutil.MustParseTimeRFC3339("2001-02-03T04:05:07Z"), + }, + { + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("dict-2"), + CreatedAt: testutil.MustParseTimeRFC3339("2001-02-03T04:05:06Z"), + WriteOnly: fastly.ToPointer(false), + DictionaryID: fastly.ToPointer("456"), + UpdatedAt: testutil.MustParseTimeRFC3339("2001-02-03T04:05:07Z"), + }, + }, nil +} + +func updateDictionaryNameOK(i *fastly.UpdateDictionaryInput) (*fastly.Dictionary, error) { + return &fastly.Dictionary{ + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: i.NewName, + CreatedAt: testutil.MustParseTimeRFC3339("2001-02-03T04:05:06Z"), + WriteOnly: fastly.ToPointer(bool(fastly.ToValue(i.WriteOnly))), + DictionaryID: fastly.ToPointer("456"), + UpdatedAt: testutil.MustParseTimeRFC3339("2001-02-03T04:05:07Z"), + }, nil +} + +func updateDictionaryWriteOnlyOK(i *fastly.UpdateDictionaryInput) (*fastly.Dictionary, error) { + return &fastly.Dictionary{ + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer(i.Name), + CreatedAt: testutil.MustParseTimeRFC3339("2001-02-03T04:05:06Z"), + WriteOnly: fastly.ToPointer(bool(fastly.ToValue(i.WriteOnly))), + DictionaryID: fastly.ToPointer("456"), + UpdatedAt: testutil.MustParseTimeRFC3339("2001-02-03T04:05:07Z"), + }, nil +} + +func updateDictionaryError(_ *fastly.UpdateDictionaryInput) (*fastly.Dictionary, error) { + return nil, errTest +} + +var ( + errTest = errors.New("an expected error occurred") + errFail = errors.New("this error should not be returned and indicates a failure in the code") +) + +var ( + createDictionaryOutput = "SUCCESS: Created dictionary denylist (id 456, service 123, version 4)\n" + createDictionaryOutputWriteOnly = "SUCCESS: Created dictionary denylist as write-only (id 456, service 123, version 4)\n" + deleteDictionaryOutput = "SUCCESS: Deleted dictionary allowlist (service 123 version 4)\n" + updateDictionaryOutput = "SUCCESS: Updated dictionary oldname (service 123 version 4)\n" + updateDictionaryNameOutput = "SUCCESS: Updated dictionary dict-1 (service 123 version 4)\n" +) + +var updateDictionaryOutputVerbose = strings.Join( + []string{ + "Fastly API endpoint: https://api.fastly.com", + "Fastly API token provided via config file (profile: user)", + "", + "Service ID (via --service-id): 123", + "", + "INFO: Service version 1 is not editable, so it was automatically cloned because --autoclone is enabled. Now operating on", + "version 4.", + "", + strings.TrimSpace(updateDictionaryNameOutput), + "", + updateDictionaryOutputVersionCloned, + }, + "\n") + +var updateDictionaryOutputVersionCloned = strings.TrimSpace(` +Version: 4 +ID: 456 +Name: dict-1 +Write Only: false +Created (UTC): 2001-02-03 04:05 +Last edited (UTC): 2001-02-03 04:05 +`) + "\n" + +var describeDictionaryOutput = strings.TrimSpace(` +Service ID: 123 +Version: 1 +ID: 456 +Name: dict-1 +Write Only: false +Created (UTC): 2001-02-03 04:05 +Last edited (UTC): 2001-02-03 04:05 +`) + "\n" + +var describeDictionaryOutputDeleted = strings.TrimSpace(` +Service ID: 123 +Version: 1 +ID: 456 +Name: dict-1 +Write Only: false +Created (UTC): 2001-02-03 04:05 +Last edited (UTC): 2001-02-03 04:05 +Deleted (UTC): 2001-02-03 04:05 +`) + "\n" + +var describeDictionaryOutputVerbose = strings.TrimSpace(` +Fastly API endpoint: https://api.fastly.com +Fastly API token provided via config file (profile: user) + +Service ID (via --service-id): 123 + +Version: 1 +ID: 456 +Name: dict-1 +Write Only: false +Created (UTC): 2001-02-03 04:05 +Last edited (UTC): 2001-02-03 04:05 +Digest: digest_hash +Item Count: 2 +Item 1/2: + Item Key: foo + Item Value: bar +Item 2/2: + Item Key: baz + Item Value: bear +`) + "\n" + +var listDictionariesOutput = "\n" + strings.TrimSpace(` +Service ID: 123 +Version: 1 +ID: 456 +Name: dict-1 +Write Only: false +Created (UTC): 2001-02-03 04:05 +Last edited (UTC): 2001-02-03 04:05 +ID: 456 +Name: dict-2 +Write Only: false +Created (UTC): 2001-02-03 04:05 +Last edited (UTC): 2001-02-03 04:05 +`) + "\n" diff --git a/pkg/commands/dictionary/doc.go b/pkg/commands/dictionary/doc.go new file mode 100644 index 000000000..0d61a4a3c --- /dev/null +++ b/pkg/commands/dictionary/doc.go @@ -0,0 +1,3 @@ +// Package dictionary contains commands to inspect and manipulate Fastly edge +// dictionaries. +package dictionary diff --git a/pkg/commands/dictionary/list.go b/pkg/commands/dictionary/list.go new file mode 100644 index 000000000..b25a45ace --- /dev/null +++ b/pkg/commands/dictionary/list.go @@ -0,0 +1,106 @@ +package dictionary + +import ( + "fmt" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// ListCommand calls the Fastly API to list dictionaries. +type ListCommand struct { + argparser.Base + argparser.JSONOutput + + Input fastly.ListDictionariesInput + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion +} + +// NewListCommand returns a usable command registered under the parent. +func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { + c := ListCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("list", "List all dictionaries on a Fastly service version") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + return &c +} + +// Exec invokes the application logic for the command. +func (c *ListCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fsterr.ServiceVersion(serviceVersion), + }) + return err + } + serviceVersionNumber := fastly.ToValue(serviceVersion.Number) + + c.Input.ServiceID = serviceID + c.Input.ServiceVersion = serviceVersionNumber + + o, err := c.Globals.APIClient.ListDictionaries(&c.Input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": serviceVersionNumber, + }) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + if !c.Globals.Verbose() { + fmt.Fprintf(out, "\nService ID: %s\n", serviceID) + } + text.Output(out, "Version: %d", c.Input.ServiceVersion) + for _, dictionary := range o { + text.PrintDictionary(out, "", dictionary) + } + + return nil +} diff --git a/pkg/commands/dictionary/root.go b/pkg/commands/dictionary/root.go new file mode 100644 index 000000000..8713f454e --- /dev/null +++ b/pkg/commands/dictionary/root.go @@ -0,0 +1,31 @@ +package dictionary + +import ( + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" +) + +// RootCommand is the parent command for all subcommands in this package. +// It should be installed under the primary root command. +type RootCommand struct { + argparser.Base + // no flags +} + +// CommandName is the string to be used to invoke this command. +const CommandName = "dictionary" + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent argparser.Registerer, g *global.Data) *RootCommand { + var c RootCommand + c.Globals = g + c.CmdClause = parent.Command(CommandName, "Manipulate Fastly edge dictionaries") + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { + panic("unreachable") +} diff --git a/pkg/commands/dictionary/update.go b/pkg/commands/dictionary/update.go new file mode 100644 index 000000000..cab7523ae --- /dev/null +++ b/pkg/commands/dictionary/update.go @@ -0,0 +1,132 @@ +package dictionary + +import ( + "fmt" + "io" + "strconv" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// UpdateCommand calls the Fastly API to update a dictionary. +type UpdateCommand struct { + argparser.Base + + // TODO: make input consistent across commands (most are title case) + input fastly.UpdateDictionaryInput + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion + autoClone argparser.OptionalAutoClone + + newname argparser.OptionalString + writeOnly argparser.OptionalString +} + +// NewUpdateCommand returns a usable command registered under the parent. +func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { + c := UpdateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("update", "Update name of dictionary on a Fastly service version").Alias("get") + + // Required. + c.CmdClause.Flag("name", "Old name of Dictionary").Short('n').Required().StringVar(&c.input.Name) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.autoClone.Set, + Dst: &c.autoClone.Value, + }) + c.CmdClause.Flag("new-name", "New name of Dictionary").Action(c.newname.Set).StringVar(&c.newname.Value) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + c.CmdClause.Flag("write-only", "Whether to mark this dictionary as write-only. Can be true or false (defaults to false)").Action(c.writeOnly.Set).StringVar(&c.writeOnly.Value) + return &c +} + +// Exec invokes the application logic for the command. +func (c *UpdateCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.autoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fsterr.ServiceVersion(serviceVersion), + }) + return err + } + + serviceVersionNumber := fastly.ToValue(serviceVersion.Number) + + c.input.ServiceID = serviceID + c.input.ServiceVersion = serviceVersionNumber + + if !c.newname.WasSet && !c.writeOnly.WasSet { + return fsterr.RemediationError{Inner: fmt.Errorf("error parsing arguments: required flag --new-name or --write-only not provided"), Remediation: "To fix this error, provide at least one of the aforementioned flags"} + } + if c.newname.WasSet { + c.input.NewName = &c.newname.Value + } + + if c.writeOnly.WasSet { + writeOnly, err := strconv.ParseBool(c.writeOnly.Value) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": serviceVersionNumber, + }) + return err + } + c.input.WriteOnly = fastly.ToPointer(fastly.Compatibool(writeOnly)) + } + + d, err := c.Globals.APIClient.UpdateDictionary(&c.input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": serviceVersionNumber, + }) + return err + } + + text.Success(out, "Updated dictionary %s (service %s version %d)", fastly.ToValue(d.Name), fastly.ToValue(d.ServiceID), fastly.ToValue(d.ServiceVersion)) + if c.Globals.Verbose() { + text.Output(out, "\nVersion: %d\n", fastly.ToValue(d.ServiceVersion)) + text.PrintDictionary(out, "", d) + } + return nil +} diff --git a/pkg/commands/dictionaryentry/create.go b/pkg/commands/dictionaryentry/create.go new file mode 100644 index 000000000..d1c7ab582 --- /dev/null +++ b/pkg/commands/dictionaryentry/create.go @@ -0,0 +1,74 @@ +package dictionaryentry + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// CreateCommand calls the Fastly API to create a dictionary item. +type CreateCommand struct { + argparser.Base + Input fastly.CreateDictionaryItemInput + itemKey, itemValue string + serviceName argparser.OptionalServiceNameID +} + +// NewCreateCommand returns a usable command registered under the parent. +func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { + c := CreateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("create", "Create a new item on a Fastly edge dictionary") + + // Required. + c.CmdClause.Flag("dictionary-id", "Dictionary ID").Required().StringVar(&c.Input.DictionaryID) + c.CmdClause.Flag("key", "Dictionary item key").Required().StringVar(&c.itemKey) + c.CmdClause.Flag("value", "Dictionary item value").Required().StringVar(&c.itemValue) + + // Optional. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + return &c +} + +// Exec invokes the application logic for the command. +func (c *CreateCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, source, flag, err := argparser.ServiceID(c.serviceName, *c.Globals.Manifest, c.Globals.APIClient, c.Globals.ErrLog) + if err != nil { + return err + } + if c.Globals.Verbose() { + argparser.DisplayServiceID(serviceID, flag, source, out) + } + + c.Input.ItemKey = &c.itemKey + c.Input.ItemValue = &c.itemValue + c.Input.ServiceID = serviceID + _, err = c.Globals.APIClient.CreateDictionaryItem(&c.Input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + }) + return err + } + + text.Success(out, "Created dictionary item %s (service %s, dictionary %s)", fastly.ToValue(c.Input.ItemKey), c.Input.ServiceID, c.Input.DictionaryID) + return nil +} diff --git a/pkg/commands/dictionaryentry/delete.go b/pkg/commands/dictionaryentry/delete.go new file mode 100644 index 000000000..172782ee2 --- /dev/null +++ b/pkg/commands/dictionaryentry/delete.go @@ -0,0 +1,70 @@ +package dictionaryentry + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// DeleteCommand calls the Fastly API to delete a service. +type DeleteCommand struct { + argparser.Base + Input fastly.DeleteDictionaryItemInput + serviceName argparser.OptionalServiceNameID +} + +// NewDeleteCommand returns a usable command registered under the parent. +func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { + c := DeleteCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("delete", "Delete an item from a Fastly edge dictionary") + + // Required. + c.CmdClause.Flag("dictionary-id", "Dictionary ID").Required().StringVar(&c.Input.DictionaryID) + c.CmdClause.Flag("key", "Dictionary item key").Required().StringVar(&c.Input.ItemKey) + + // Optional. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + return &c +} + +// Exec invokes the application logic for the command. +func (c *DeleteCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, source, flag, err := argparser.ServiceID(c.serviceName, *c.Globals.Manifest, c.Globals.APIClient, c.Globals.ErrLog) + if err != nil { + return err + } + if c.Globals.Verbose() { + argparser.DisplayServiceID(serviceID, flag, source, out) + } + + c.Input.ServiceID = serviceID + err = c.Globals.APIClient.DeleteDictionaryItem(&c.Input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + }) + return err + } + + text.Success(out, "Deleted dictionary item %s (service %s, dictionary %s)", c.Input.ItemKey, c.Input.ServiceID, c.Input.DictionaryID) + return nil +} diff --git a/pkg/commands/dictionaryentry/describe.go b/pkg/commands/dictionaryentry/describe.go new file mode 100644 index 000000000..86d9faeba --- /dev/null +++ b/pkg/commands/dictionaryentry/describe.go @@ -0,0 +1,88 @@ +package dictionaryentry + +import ( + "fmt" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// DescribeCommand calls the Fastly API to describe a dictionary item. +type DescribeCommand struct { + argparser.Base + argparser.JSONOutput + + Input fastly.GetDictionaryItemInput + serviceName argparser.OptionalServiceNameID +} + +// NewDescribeCommand returns a usable command registered under the parent. +func NewDescribeCommand(parent argparser.Registerer, g *global.Data) *DescribeCommand { + c := DescribeCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("describe", "Show detailed information about a Fastly edge dictionary item").Alias("get") + + // Required. + c.CmdClause.Flag("dictionary-id", "Dictionary ID").Required().StringVar(&c.Input.DictionaryID) + c.CmdClause.Flag("key", "Dictionary item key").Required().StringVar(&c.Input.ItemKey) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + + return &c +} + +// Exec invokes the application logic for the command. +func (c *DescribeCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + serviceID, source, flag, err := argparser.ServiceID(c.serviceName, *c.Globals.Manifest, c.Globals.APIClient, c.Globals.ErrLog) + if err != nil { + return err + } + if c.Globals.Verbose() { + argparser.DisplayServiceID(serviceID, flag, source, out) + } + + c.Input.ServiceID = serviceID + + o, err := c.Globals.APIClient.GetDictionaryItem(&c.Input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + }) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + if !c.Globals.Verbose() { + fmt.Fprintf(out, "\nService ID: %s\n", c.Input.ServiceID) + } + text.PrintDictionaryItem(out, "", o) + return nil +} diff --git a/pkg/commands/dictionaryentry/dictionaryitem_test.go b/pkg/commands/dictionaryentry/dictionaryitem_test.go new file mode 100644 index 000000000..598a340fe --- /dev/null +++ b/pkg/commands/dictionaryentry/dictionaryitem_test.go @@ -0,0 +1,430 @@ +package dictionaryentry_test + +import ( + "bytes" + "errors" + "io" + "net/http" + "os" + "strings" + "testing" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/app" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" +) + +func TestDictionaryItemDescribe(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("dictionary-entry describe --service-id 123 --key foo"), + api: mock.API{GetDictionaryItemFn: describeDictionaryItemOK}, + wantError: "error parsing arguments: required flag --dictionary-id not provided", + }, + { + args: args("dictionary-entry describe --service-id 123 --dictionary-id 456"), + api: mock.API{GetDictionaryItemFn: describeDictionaryItemOK}, + wantError: "error parsing arguments: required flag --key not provided", + }, + { + args: args("dictionary-entry describe --service-id 123 --dictionary-id 456 --key foo"), + api: mock.API{GetDictionaryItemFn: describeDictionaryItemOK}, + wantOutput: describeDictionaryItemOutput, + }, + { + args: args("dictionary-entry describe --service-id 123 --dictionary-id 456 --key foo-deleted"), + api: mock.API{GetDictionaryItemFn: describeDictionaryItemOKDeleted}, + wantOutput: describeDictionaryItemOutputDeleted, + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertString(t, testcase.wantOutput, stdout.String()) + }) + } +} + +func TestDictionaryItemsList(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("dictionary-entry list --service-id 123"), + wantError: "error parsing arguments: required flag --dictionary-id not provided", + }, + { + args: args("dictionary-entry list --dictionary-id 456"), + wantError: "error reading service: no service ID found", + }, + { + api: mock.API{ + GetDictionaryItemsFn: func(_ *fastly.GetDictionaryItemsInput) *fastly.ListPaginator[fastly.DictionaryItem] { + return fastly.NewPaginator[fastly.DictionaryItem](&mock.HTTPClient{ + Errors: []error{ + testutil.Err, + }, + Responses: []*http.Response{nil}, + }, fastly.ListOpts{}, "/example") + }, + }, + args: args("dictionary-entry list --service-id 123 --dictionary-id 456"), + wantError: testutil.Err.Error(), + }, + { + api: mock.API{ + GetDictionaryItemsFn: func(_ *fastly.GetDictionaryItemsInput) *fastly.ListPaginator[fastly.DictionaryItem] { + return fastly.NewPaginator[fastly.DictionaryItem](&mock.HTTPClient{ + Errors: []error{nil}, + Responses: []*http.Response{ + { + Body: io.NopCloser(strings.NewReader(`[ + { + "dictionary_id": "123", + "item_key": "foo", + "item_value": "bar", + "created_at": "2021-06-15T23:00:00Z", + "updated_at": "2021-06-15T23:00:00Z" + }, + { + "dictionary_id": "456", + "item_key": "baz", + "item_value": "qux", + "created_at": "2021-06-15T23:00:00Z", + "updated_at": "2021-06-15T23:00:00Z", + "deleted_at": "2021-06-15T23:00:00Z" + } + ]`)), + }, + }, + }, fastly.ListOpts{}, "/example") + }, + }, + args: args("dictionary-entry list --service-id 123 --dictionary-id 456 --per-page 1"), + wantOutput: listDictionaryItemsOutput, + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertString(t, testcase.wantOutput, stdout.String()) + }) + } +} + +func TestDictionaryItemCreate(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("dictionary-entry create --service-id 123"), + api: mock.API{CreateDictionaryItemFn: createDictionaryItemOK}, + wantError: "error parsing arguments: required flag ", + }, + { + args: args("dictionary-entry create --service-id 123 --dictionary-id 456"), + api: mock.API{CreateDictionaryItemFn: createDictionaryItemOK}, + wantError: "error parsing arguments: required flag ", + }, + { + args: args("dictionary-entry create --service-id 123 --dictionary-id 456 --key foo --value bar"), + api: mock.API{CreateDictionaryItemFn: createDictionaryItemOK}, + wantOutput: "SUCCESS: Created dictionary item foo (service 123, dictionary 456)\n", + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertString(t, testcase.wantOutput, stdout.String()) + }) + } +} + +func TestDictionaryItemUpdate(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + fileData string + wantError string + wantOutput string + }{ + { + args: args("dictionary-entry update --service-id 123"), + api: mock.API{UpdateDictionaryItemFn: updateDictionaryItemOK}, + wantError: "error parsing arguments: required flag --dictionary-id not provided", + }, + { + args: args("dictionary-entry update --service-id 123 --dictionary-id 456"), + api: mock.API{UpdateDictionaryItemFn: updateDictionaryItemOK}, + wantError: "an empty value is not allowed for either the '--key' or '--value' flags", + }, + { + args: args("dictionary-entry update --service-id 123 --dictionary-id 456 --key foo --value bar"), + api: mock.API{UpdateDictionaryItemFn: updateDictionaryItemOK}, + wantOutput: updateDictionaryItemOutput, + }, + { + args: args("dictionary-entry update --service-id 123 --dictionary-id 456 --file filePath"), + fileData: `{invalid": "json"}`, + wantError: "invalid character 'i' looking for beginning of object key string", + }, + // NOTE: We don't specify the full error value in the wantError field + // because this would cause an error on different OS'. For example, Unix + // systems report 'no such file or directory', while Windows will report + // 'The system cannot find the file specified'. + { + args: args("dictionary-entry update --service-id 123 --dictionary-id 456 --file missingPath"), + wantError: "open missingPath:", + }, + { + args: args("dictionary-entry update --service-id 123 --dictionary-id 456 --file filePath"), + fileData: dictionaryItemBatchModifyInputOK, + api: mock.API{BatchModifyDictionaryItemsFn: batchModifyDictionaryItemsError}, + wantError: errTest.Error(), + }, + { + args: args("dictionary-entry update --service-id 123 --dictionary-id 456 --file filePath"), + fileData: dictionaryItemBatchModifyInputOK, + api: mock.API{BatchModifyDictionaryItemsFn: batchModifyDictionaryItemsOK}, + wantOutput: "SUCCESS: Made 4 modifications of Dictionary 456 on service 123\n", + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var filePath string + if testcase.fileData != "" { + filePath = testutil.MakeTempFile(t, testcase.fileData) + defer os.RemoveAll(filePath) + } + + // Insert temp file path into args when "filePath" is present as placeholder + for i, v := range testcase.args { + if v == "filePath" { + testcase.args[i] = filePath + } + } + + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertString(t, testcase.wantOutput, stdout.String()) + }) + } +} + +func TestDictionaryItemDelete(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("dictionary-entry delete --service-id 123"), + api: mock.API{DeleteDictionaryItemFn: deleteDictionaryItemOK}, + wantError: "error parsing arguments: required flag ", + }, + { + args: args("dictionary-entry delete --service-id 123 --dictionary-id 456"), + api: mock.API{DeleteDictionaryItemFn: deleteDictionaryItemOK}, + wantError: "error parsing arguments: required flag ", + }, + { + args: args("dictionary-entry delete --service-id 123 --dictionary-id 456 --key foo"), + api: mock.API{DeleteDictionaryItemFn: deleteDictionaryItemOK}, + wantOutput: "SUCCESS: Deleted dictionary item foo (service 123, dictionary 456)\n", + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertString(t, testcase.wantOutput, stdout.String()) + }) + } +} + +func describeDictionaryItemOK(i *fastly.GetDictionaryItemInput) (*fastly.DictionaryItem, error) { + return &fastly.DictionaryItem{ + ServiceID: fastly.ToPointer(i.ServiceID), + DictionaryID: fastly.ToPointer(i.DictionaryID), + ItemKey: fastly.ToPointer(i.ItemKey), + ItemValue: fastly.ToPointer("bar"), + CreatedAt: testutil.MustParseTimeRFC3339("2001-02-03T04:05:06Z"), + UpdatedAt: testutil.MustParseTimeRFC3339("2001-02-03T04:05:07Z"), + }, nil +} + +var describeDictionaryItemOutput = "\n" + `Service ID: 123 +Dictionary ID: 456 +Item Key: foo +Item Value: bar +Created (UTC): 2001-02-03 04:05 +Last edited (UTC): 2001-02-03 04:05 +` + +var updateDictionaryItemOutput = `SUCCESS: Updated dictionary item (service 123) + +Dictionary ID: 456 +Item Key: foo +Item Value: bar +Created (UTC): 2001-02-03 04:05 +Last edited (UTC): 2001-02-03 04:05 +` + +func describeDictionaryItemOKDeleted(i *fastly.GetDictionaryItemInput) (*fastly.DictionaryItem, error) { + return &fastly.DictionaryItem{ + ServiceID: fastly.ToPointer(i.ServiceID), + DictionaryID: fastly.ToPointer(i.DictionaryID), + ItemKey: fastly.ToPointer(i.ItemKey), + ItemValue: fastly.ToPointer("bar"), + CreatedAt: testutil.MustParseTimeRFC3339("2001-02-03T04:05:06Z"), + UpdatedAt: testutil.MustParseTimeRFC3339("2001-02-03T04:05:07Z"), + DeletedAt: testutil.MustParseTimeRFC3339("2001-02-03T04:06:08Z"), + }, nil +} + +var describeDictionaryItemOutputDeleted = "\n" + strings.TrimSpace(` +Service ID: 123 +Dictionary ID: 456 +Item Key: foo-deleted +Item Value: bar +Created (UTC): 2001-02-03 04:05 +Last edited (UTC): 2001-02-03 04:05 +Deleted (UTC): 2001-02-03 04:06 +`) + "\n" + +var listDictionaryItemsOutput = "\n" + strings.TrimSpace(` +Service ID: 123 +Item: 1/2 + Dictionary ID: 123 + Item Key: foo + Item Value: bar + Created (UTC): 2021-06-15 23:00 + Last edited (UTC): 2021-06-15 23:00 + +Item: 2/2 + Dictionary ID: 456 + Item Key: baz + Item Value: qux + Created (UTC): 2021-06-15 23:00 + Last edited (UTC): 2021-06-15 23:00 + Deleted (UTC): 2021-06-15 23:00 +`) + "\n\n" + +func createDictionaryItemOK(i *fastly.CreateDictionaryItemInput) (*fastly.DictionaryItem, error) { + return &fastly.DictionaryItem{ + ServiceID: fastly.ToPointer(i.ServiceID), + DictionaryID: fastly.ToPointer(i.DictionaryID), + ItemKey: i.ItemKey, + ItemValue: i.ItemValue, + CreatedAt: testutil.MustParseTimeRFC3339("2001-02-03T04:05:06Z"), + UpdatedAt: testutil.MustParseTimeRFC3339("2001-02-03T04:05:07Z"), + }, nil +} + +func updateDictionaryItemOK(i *fastly.UpdateDictionaryItemInput) (*fastly.DictionaryItem, error) { + return &fastly.DictionaryItem{ + ServiceID: fastly.ToPointer(i.ServiceID), + DictionaryID: fastly.ToPointer(i.DictionaryID), + ItemKey: fastly.ToPointer(i.ItemKey), + ItemValue: fastly.ToPointer(i.ItemValue), + CreatedAt: testutil.MustParseTimeRFC3339("2001-02-03T04:05:06Z"), + UpdatedAt: testutil.MustParseTimeRFC3339("2001-02-03T04:05:07Z"), + }, nil +} + +func deleteDictionaryItemOK(_ *fastly.DeleteDictionaryItemInput) error { + return nil +} + +var dictionaryItemBatchModifyInputOK = ` +{ + "items": [ + { + "op": "create", + "item_key": "some_key", + "item_value": "new_value" + }, + { + "op": "update", + "item_key": "some_key", + "item_value": "new_value" + }, + { + "op": "upsert", + "item_key": "some_key", + "item_value": "new_value" + }, + { + "op": "delete", + "item_key": "some_key" + } + ] +}` + +func batchModifyDictionaryItemsOK(_ *fastly.BatchModifyDictionaryItemsInput) error { + return nil +} + +func batchModifyDictionaryItemsError(_ *fastly.BatchModifyDictionaryItemsInput) error { + return errTest +} + +var errTest = errors.New("an expected error occurred") diff --git a/pkg/commands/dictionaryentry/doc.go b/pkg/commands/dictionaryentry/doc.go new file mode 100644 index 000000000..60a5f7fc2 --- /dev/null +++ b/pkg/commands/dictionaryentry/doc.go @@ -0,0 +1,3 @@ +// Package dictionaryentry contains commands to inspect and manipulate Fastly edge +// dictionary items. +package dictionaryentry diff --git a/pkg/commands/dictionaryentry/list.go b/pkg/commands/dictionaryentry/list.go new file mode 100644 index 000000000..cda7dad23 --- /dev/null +++ b/pkg/commands/dictionaryentry/list.go @@ -0,0 +1,109 @@ +package dictionaryentry + +import ( + "fmt" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// ListCommand calls the Fastly API to list dictionary items. +type ListCommand struct { + argparser.Base + argparser.JSONOutput + + direction string + input fastly.GetDictionaryItemsInput + page, perPage int + serviceName argparser.OptionalServiceNameID + sort string +} + +// NewListCommand returns a usable command registered under the parent. +func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { + c := ListCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("list", "List items in a Fastly edge dictionary") + + // Required. + c.CmdClause.Flag("dictionary-id", "Dictionary ID").Required().StringVar(&c.input.DictionaryID) + + // Optional. + c.CmdClause.Flag("direction", "Direction in which to sort results").Default(argparser.PaginationDirection[0]).HintOptions(argparser.PaginationDirection...).EnumVar(&c.direction, argparser.PaginationDirection...) + c.RegisterFlagBool(c.JSONFlag()) // --json + c.CmdClause.Flag("page", "Page number of data set to fetch").IntVar(&c.page) + c.CmdClause.Flag("per-page", "Number of records per page").IntVar(&c.perPage) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + c.CmdClause.Flag("sort", "Field on which to sort").Default("created").StringVar(&c.sort) + return &c +} + +// Exec invokes the application logic for the command. +func (c *ListCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + serviceID, source, flag, err := argparser.ServiceID(c.serviceName, *c.Globals.Manifest, c.Globals.APIClient, c.Globals.ErrLog) + if err != nil { + return err + } + if c.Globals.Verbose() { + argparser.DisplayServiceID(serviceID, flag, source, out) + } + + c.input.Direction = &c.direction + c.input.Page = &c.page + c.input.PerPage = &c.perPage + c.input.ServiceID = serviceID + c.input.Sort = &c.sort + paginator := c.Globals.APIClient.GetDictionaryItems(&c.input) + + var o []*fastly.DictionaryItem + for paginator.HasNext() { + data, err := paginator.GetNext() + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Dictionary ID": c.input.DictionaryID, + "Service ID": serviceID, + "Remaining Pages": paginator.Remaining(), + }) + return err + } + o = append(o, data...) + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + if !c.Globals.Verbose() { + fmt.Fprintf(out, "\nService ID: %s\n", c.input.ServiceID) + } + for i, dictionary := range o { + text.Output(out, "Item: %d/%d", i+1, len(o)) + text.PrintDictionaryItem(out, "\t", dictionary) + text.Break(out) + } + + return nil +} diff --git a/pkg/commands/dictionaryentry/root.go b/pkg/commands/dictionaryentry/root.go new file mode 100644 index 000000000..bbc8bbcf4 --- /dev/null +++ b/pkg/commands/dictionaryentry/root.go @@ -0,0 +1,31 @@ +package dictionaryentry + +import ( + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" +) + +// RootCommand is the parent command for all subcommands in this package. +// It should be installed under the primary root command. +type RootCommand struct { + argparser.Base + // no flags +} + +// CommandName is the string to be used to invoke this command. +const CommandName = "dictionary-entry" + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent argparser.Registerer, g *global.Data) *RootCommand { + var c RootCommand + c.Globals = g + c.CmdClause = parent.Command(CommandName, "Manipulate Fastly edge dictionary items") + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { + panic("unreachable") +} diff --git a/pkg/commands/dictionaryentry/update.go b/pkg/commands/dictionaryentry/update.go new file mode 100644 index 000000000..6341c04fd --- /dev/null +++ b/pkg/commands/dictionaryentry/update.go @@ -0,0 +1,126 @@ +package dictionaryentry + +import ( + "encoding/json" + "fmt" + "io" + "os" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// UpdateCommand calls the Fastly API to update a dictionary item. +type UpdateCommand struct { + argparser.Base + + Input fastly.UpdateDictionaryItemInput + InputBatch fastly.BatchModifyDictionaryItemsInput + file argparser.OptionalString + serviceName argparser.OptionalServiceNameID +} + +// NewUpdateCommand returns a usable command registered under the parent. +func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { + c := UpdateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("update", "Update or insert an item on a Fastly edge dictionary") + + // Required. + c.CmdClause.Flag("dictionary-id", "Dictionary ID").Required().StringVar(&c.Input.DictionaryID) + + // Optional. + c.CmdClause.Flag("file", "Batch update json file").Action(c.file.Set).StringVar(&c.file.Value) + c.CmdClause.Flag("key", "Dictionary item key").StringVar(&c.Input.ItemKey) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + c.CmdClause.Flag("value", "Dictionary item value").StringVar(&c.Input.ItemValue) + return &c +} + +// Exec invokes the application logic for the command. +func (c *UpdateCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, source, flag, err := argparser.ServiceID(c.serviceName, *c.Globals.Manifest, c.Globals.APIClient, c.Globals.ErrLog) + if err != nil { + return err + } + if c.Globals.Verbose() { + argparser.DisplayServiceID(serviceID, flag, source, out) + } + + c.Input.ServiceID = serviceID + c.InputBatch.ServiceID = serviceID + c.InputBatch.DictionaryID = c.Input.DictionaryID + + if c.file.WasSet { + err := c.batchModify(out) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + return nil + } + + if c.Input.ItemKey == "" || c.Input.ItemValue == "" { + return fmt.Errorf("an empty value is not allowed for either the '--key' or '--value' flags") + } + + d, err := c.Globals.APIClient.UpdateDictionaryItem(&c.Input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + text.Success(out, "Updated dictionary item (service %s)\n\n", fastly.ToValue(d.ServiceID)) + text.PrintDictionaryItem(out, "", d) + return nil +} + +func (c *UpdateCommand) batchModify(out io.Writer) error { + jsonFile, err := os.Open(c.file.Value) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + jsonBytes, err := io.ReadAll(jsonFile) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + err = json.Unmarshal(jsonBytes, &c.InputBatch) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if len(c.InputBatch.Items) == 0 { + return fmt.Errorf("item key not found in file %s", c.file.Value) + } + + err = c.Globals.APIClient.BatchModifyDictionaryItems(&c.InputBatch) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + text.Success(out, "Made %d modifications of Dictionary %s on service %s", len(c.InputBatch.Items), c.Input.DictionaryID, c.InputBatch.ServiceID) + return nil +} diff --git a/pkg/commands/doc.go b/pkg/commands/doc.go new file mode 100644 index 000000000..41caccb96 --- /dev/null +++ b/pkg/commands/doc.go @@ -0,0 +1,2 @@ +// Package commands contains functions for managing exposed CLI commands. +package commands diff --git a/pkg/commands/domain/create.go b/pkg/commands/domain/create.go new file mode 100644 index 000000000..729c54c4f --- /dev/null +++ b/pkg/commands/domain/create.go @@ -0,0 +1,110 @@ +package domain + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// CreateCommand calls the Fastly API to create domains. +type CreateCommand struct { + argparser.Base + + // Required. + serviceVersion argparser.OptionalServiceVersion + + // Optional. + autoClone argparser.OptionalAutoClone + comment argparser.OptionalString + name argparser.OptionalString + serviceName argparser.OptionalServiceNameID +} + +// NewCreateCommand returns a usable command registered under the parent. +func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { + c := CreateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("create", "Create a domain on a Fastly service version").Alias("add") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.autoClone.Set, + Dst: &c.autoClone.Value, + }) + c.CmdClause.Flag("comment", "A descriptive note").Action(c.comment.Set).StringVar(&c.comment.Value) + c.CmdClause.Flag("name", "Domain name").Short('n').Action(c.name.Set).StringVar(&c.name.Value) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + return &c +} + +// Exec invokes the application logic for the command. +func (c *CreateCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.autoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + input := fastly.CreateDomainInput{ + ServiceID: serviceID, + ServiceVersion: fastly.ToValue(serviceVersion.Number), + } + if c.name.WasSet { + input.Name = &c.name.Value + } + if c.comment.WasSet { + input.Comment = &c.comment.Value + } + d, err := c.Globals.APIClient.CreateDomain(&input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fastly.ToValue(serviceVersion.Number), + }) + return err + } + + text.Success(out, "Created domain %s (service %s version %d)", fastly.ToValue(d.Name), fastly.ToValue(d.ServiceID), fastly.ToValue(d.ServiceVersion)) + return nil +} diff --git a/pkg/commands/domain/delete.go b/pkg/commands/domain/delete.go new file mode 100644 index 000000000..fe33a5d24 --- /dev/null +++ b/pkg/commands/domain/delete.go @@ -0,0 +1,97 @@ +package domain + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// DeleteCommand calls the Fastly API to delete domains. +type DeleteCommand struct { + argparser.Base + Input fastly.DeleteDomainInput + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion + autoClone argparser.OptionalAutoClone +} + +// NewDeleteCommand returns a usable command registered under the parent. +func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { + c := DeleteCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("delete", "Delete a domain on a Fastly service version").Alias("remove") + + // Required. + c.CmdClause.Flag("name", "Domain name").Short('n').Required().StringVar(&c.Input.Name) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.autoClone.Set, + Dst: &c.autoClone.Value, + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + return &c +} + +// Exec invokes the application logic for the command. +func (c *DeleteCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.autoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + c.Input.ServiceID = serviceID + c.Input.ServiceVersion = fastly.ToValue(serviceVersion.Number) + + if err := c.Globals.APIClient.DeleteDomain(&c.Input); err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fastly.ToValue(serviceVersion.Number), + }) + return err + } + + text.Success(out, "Deleted domain %s (service %s version %d)", c.Input.Name, c.Input.ServiceID, c.Input.ServiceVersion) + return nil +} diff --git a/pkg/commands/domain/describe.go b/pkg/commands/domain/describe.go new file mode 100644 index 000000000..cf4886da5 --- /dev/null +++ b/pkg/commands/domain/describe.go @@ -0,0 +1,105 @@ +package domain + +import ( + "fmt" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" +) + +// DescribeCommand calls the Fastly API to describe a domain. +type DescribeCommand struct { + argparser.Base + argparser.JSONOutput + + Input fastly.GetDomainInput + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion +} + +// NewDescribeCommand returns a usable command registered under the parent. +func NewDescribeCommand(parent argparser.Registerer, g *global.Data) *DescribeCommand { + c := DescribeCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("describe", "Show detailed information about a domain on a Fastly service version").Alias("get") + + // Required. + c.CmdClause.Flag("name", "Name of domain").Short('n').Required().StringVar(&c.Input.Name) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + return &c +} + +// Exec invokes the application logic for the command. +func (c *DescribeCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fsterr.ServiceVersion(serviceVersion), + }) + return err + } + + c.Input.ServiceID = serviceID + c.Input.ServiceVersion = fastly.ToValue(serviceVersion.Number) + + o, err := c.Globals.APIClient.GetDomain(&c.Input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fastly.ToValue(serviceVersion.Number), + }) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + if !c.Globals.Verbose() { + fmt.Fprintf(out, "\nService ID: %s\n", fastly.ToValue(o.ServiceID)) + } + fmt.Fprintf(out, "Version: %d\n", fastly.ToValue(o.ServiceVersion)) + fmt.Fprintf(out, "Name: %s\n", fastly.ToValue(o.Name)) + fmt.Fprintf(out, "Comment: %v\n", fastly.ToValue(o.Comment)) + + return nil +} diff --git a/pkg/domain/doc.go b/pkg/commands/domain/doc.go similarity index 100% rename from pkg/domain/doc.go rename to pkg/commands/domain/doc.go diff --git a/pkg/commands/domain/domain_test.go b/pkg/commands/domain/domain_test.go new file mode 100644 index 000000000..671b1e7d4 --- /dev/null +++ b/pkg/commands/domain/domain_test.go @@ -0,0 +1,415 @@ +package domain_test + +import ( + "errors" + "fmt" + "strings" + "testing" + + "github.com/fastly/go-fastly/v10/fastly" + + root "github.com/fastly/cli/pkg/commands/domain" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" +) + +func TestDomainCreate(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Args: "--version 1", + WantError: "error reading service: no service ID found", + }, + { + Args: "--service-id 123 --version 1 --name www.test.com --autoclone", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + CreateDomainFn: createDomainOK, + }, + WantOutput: "Created domain www.test.com (service 123 version 4)", + }, + { + Args: "--service-id 123 --version 1 --name www.test.com --autoclone", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + CreateDomainFn: createDomainError, + }, + WantError: errTest.Error(), + }, + } + testutil.RunCLIScenarios(t, []string{root.CommandName, "create"}, scenarios) +} + +func TestDomainList(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Args: "--service-id 123 --version 1", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListDomainsFn: listDomainsOK, + }, + WantOutput: listDomainsShortOutput, + }, + { + Args: "--service-id 123 --version 1 --verbose", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListDomainsFn: listDomainsOK, + }, + WantOutput: listDomainsVerboseOutput, + }, + { + Args: "--service-id 123 --version 1 -v", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListDomainsFn: listDomainsOK, + }, + WantOutput: listDomainsVerboseOutput, + }, + { + Args: "--verbose --service-id 123 --version 1", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListDomainsFn: listDomainsOK, + }, + WantOutput: listDomainsVerboseOutput, + }, + { + Args: "-v --service-id 123 --version 1", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListDomainsFn: listDomainsOK, + }, + WantOutput: listDomainsVerboseOutput, + }, + { + Args: "--service-id 123 --version 1", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListDomainsFn: listDomainsError, + }, + WantError: errTest.Error(), + }, + } + testutil.RunCLIScenarios(t, []string{root.CommandName, "list"}, scenarios) +} + +func TestDomainDescribe(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Args: "--service-id 123 --version 1", + WantError: "error parsing arguments: required flag --name not provided", + }, + { + Args: "--service-id 123 --version 1 --name www.test.com", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + GetDomainFn: getDomainError, + }, + WantError: errTest.Error(), + }, + { + Args: "--service-id 123 --version 1 --name www.test.com", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + GetDomainFn: getDomainOK, + }, + WantOutput: describeDomainOutput, + }, + } + testutil.RunCLIScenarios(t, []string{root.CommandName, "describe"}, scenarios) +} + +func TestDomainUpdate(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Args: "--service-id 123 --version 1 --new-name www.test.com --comment ", + WantError: "error parsing arguments: required flag --name not provided", + }, + { + Args: "--service-id 123 --version 1 --name www.test.com --autoclone", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + UpdateDomainFn: updateDomainOK, + }, + WantError: "error parsing arguments: must provide either --new-name or --comment to update domain", + }, + { + Args: "--service-id 123 --version 1 --name www.test.com --new-name www.example.com --autoclone", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + UpdateDomainFn: updateDomainError, + }, + WantError: errTest.Error(), + }, + { + Args: "--service-id 123 --version 1 --name www.test.com --new-name www.example.com --autoclone", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + UpdateDomainFn: updateDomainOK, + }, + WantOutput: "Updated domain www.example.com (service 123 version 4)", + }, + } + testutil.RunCLIScenarios(t, []string{root.CommandName, "update"}, scenarios) +} + +func TestDomainDelete(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Args: "--service-id 123 --version 1", + WantError: "error parsing arguments: required flag --name not provided", + }, + { + Args: "--service-id 123 --version 1 --name www.test.com --autoclone", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + DeleteDomainFn: deleteDomainError, + }, + WantError: errTest.Error(), + }, + { + Args: "--service-id 123 --version 1 --name www.test.com --autoclone", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + DeleteDomainFn: deleteDomainOK, + }, + WantOutput: "Deleted domain www.test.com (service 123 version 4)", + }, + } + testutil.RunCLIScenarios(t, []string{root.CommandName, "delete"}, scenarios) +} + +func TestDomainValidate(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --version flag", + WantError: "error parsing arguments: required flag --version not provided", + }, + { + Name: "validate missing --service-id flag", + Args: "--version 3", + WantError: "error reading service: no service ID found", + }, + { + Name: "validate missing --name flag", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + }, + Args: "--service-id 123 --version 3", + WantError: "error parsing arguments: must provide --name flag", + }, + { + Name: "validate ValidateDomain API error", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + ValidateDomainFn: func(_ *fastly.ValidateDomainInput) (*fastly.DomainValidationResult, error) { + return nil, testutil.Err + }, + }, + Args: "--name foo.example.com --service-id 123 --version 3", + WantError: testutil.Err.Error(), + }, + { + Name: "validate ValidateAllDomains API error", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + ValidateAllDomainsFn: func(_ *fastly.ValidateAllDomainsInput) ([]*fastly.DomainValidationResult, error) { + return nil, testutil.Err + }, + }, + Args: "--all --service-id 123 --version 3", + WantError: testutil.Err.Error(), + }, + { + Name: "validate ValidateDomain API success", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + ValidateDomainFn: validateDomain, + }, + Args: "--name foo.example.com --service-id 123 --version 3", + WantOutput: validateAPISuccess(3), + }, + { + Name: "validate ValidateAllDomains API success", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + ValidateAllDomainsFn: validateAllDomains, + }, + Args: "--all --service-id 123 --version 3", + WantOutput: validateAllAPISuccess(), + }, + { + Name: "validate missing --autoclone flag is OK", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + ValidateDomainFn: validateDomain, + }, + Args: "--name foo.example.com --service-id 123 --version 1", + WantOutput: validateAPISuccess(1), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, "validate"}, scenarios) +} + +var errTest = errors.New("fixture error") + +func createDomainOK(i *fastly.CreateDomainInput) (*fastly.Domain, error) { + return &fastly.Domain{ + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: i.Name, + }, nil +} + +func createDomainError(_ *fastly.CreateDomainInput) (*fastly.Domain, error) { + return nil, errTest +} + +func listDomainsOK(i *fastly.ListDomainsInput) ([]*fastly.Domain, error) { + return []*fastly.Domain{ + { + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("www.test.com"), + Comment: fastly.ToPointer("test"), + }, + { + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("www.example.com"), + Comment: fastly.ToPointer("example"), + }, + }, nil +} + +func listDomainsError(_ *fastly.ListDomainsInput) ([]*fastly.Domain, error) { + return nil, errTest +} + +var listDomainsShortOutput = strings.TrimSpace(` +SERVICE VERSION NAME COMMENT +123 1 www.test.com test +123 1 www.example.com example +`) + "\n" + +var listDomainsVerboseOutput = strings.TrimSpace(` +Fastly API endpoint: https://api.fastly.com +Fastly API token provided via config file (profile: user) + +Service ID (via --service-id): 123 + +Version: 1 + Domain 1/2 + Name: www.test.com + Comment: test + Domain 2/2 + Name: www.example.com + Comment: example +`) + "\n\n" + +func getDomainOK(i *fastly.GetDomainInput) (*fastly.Domain, error) { + return &fastly.Domain{ + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer(i.Name), + Comment: fastly.ToPointer("test"), + }, nil +} + +func getDomainError(_ *fastly.GetDomainInput) (*fastly.Domain, error) { + return nil, errTest +} + +var describeDomainOutput = "\n" + strings.TrimSpace(` +Service ID: 123 +Version: 1 +Name: www.test.com +Comment: test +`) + "\n" + +func updateDomainOK(i *fastly.UpdateDomainInput) (*fastly.Domain, error) { + return &fastly.Domain{ + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: i.NewName, + }, nil +} + +func updateDomainError(_ *fastly.UpdateDomainInput) (*fastly.Domain, error) { + return nil, errTest +} + +func deleteDomainOK(_ *fastly.DeleteDomainInput) error { + return nil +} + +func deleteDomainError(_ *fastly.DeleteDomainInput) error { + return errTest +} + +func validateDomain(i *fastly.ValidateDomainInput) (*fastly.DomainValidationResult, error) { + return &fastly.DomainValidationResult{ + Metadata: &fastly.DomainMetadata{ + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer(i.Name), + }, + CName: fastly.ToPointer("foo"), + Valid: fastly.ToPointer(true), + }, nil +} + +func validateAllDomains(i *fastly.ValidateAllDomainsInput) (results []*fastly.DomainValidationResult, err error) { + return []*fastly.DomainValidationResult{ + { + Metadata: &fastly.DomainMetadata{ + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("foo.example.com"), + }, + CName: fastly.ToPointer("foo"), + Valid: fastly.ToPointer(true), + }, + { + Metadata: &fastly.DomainMetadata{ + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("bar.example.com"), + }, + CName: fastly.ToPointer("bar"), + Valid: fastly.ToPointer(true), + }, + }, nil +} + +func validateAPISuccess(version int) string { + return fmt.Sprintf(` +Service ID: 123 +Service Version: %d + +Name: foo.example.com +Valid: true +CNAME: foo`, version) +} + +func validateAllAPISuccess() string { + return ` +Service ID: 123 +Service Version: 3 + +Name: foo.example.com +Valid: true +CNAME: foo + +Name: bar.example.com +Valid: true +CNAME: bar` +} diff --git a/pkg/commands/domain/list.go b/pkg/commands/domain/list.go new file mode 100644 index 000000000..20c64083f --- /dev/null +++ b/pkg/commands/domain/list.go @@ -0,0 +1,121 @@ +package domain + +import ( + "fmt" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// ListCommand calls the Fastly API to list domains. +type ListCommand struct { + argparser.Base + argparser.JSONOutput + + Input fastly.ListDomainsInput + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion +} + +// NewListCommand returns a usable command registered under the parent. +func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { + c := ListCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("list", "List domains on a Fastly service version") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + return &c +} + +// Exec invokes the application logic for the command. +func (c *ListCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fsterr.ServiceVersion(serviceVersion), + }) + return err + } + + c.Input.ServiceID = serviceID + c.Input.ServiceVersion = fastly.ToValue(serviceVersion.Number) + + o, err := c.Globals.APIClient.ListDomains(&c.Input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fastly.ToValue(serviceVersion.Number), + }) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + if !c.Globals.Verbose() { + tw := text.NewTable(out) + tw.AddHeader("SERVICE", "VERSION", "NAME", "COMMENT") + for _, domain := range o { + tw.AddLine( + fastly.ToValue(domain.ServiceID), + fastly.ToValue(domain.ServiceVersion), + fastly.ToValue(domain.Name), + fastly.ToValue(domain.Comment), + ) + } + tw.Print() + return nil + } + + fmt.Fprintf(out, "Version: %d\n", c.Input.ServiceVersion) + for i, domain := range o { + fmt.Fprintf(out, "\tDomain %d/%d\n", i+1, len(o)) + fmt.Fprintf(out, "\t\tName: %s\n", fastly.ToValue(domain.Name)) + fmt.Fprintf(out, "\t\tComment: %v\n", fastly.ToValue(domain.Comment)) + } + fmt.Fprintln(out) + + return nil +} diff --git a/pkg/commands/domain/root.go b/pkg/commands/domain/root.go new file mode 100644 index 000000000..560814c35 --- /dev/null +++ b/pkg/commands/domain/root.go @@ -0,0 +1,31 @@ +package domain + +import ( + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" +) + +// RootCommand is the parent command for all subcommands in this package. +// It should be installed under the primary root command. +type RootCommand struct { + argparser.Base + // no flags +} + +// CommandName is the string to be used to invoke this command. +const CommandName = "domain" + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent argparser.Registerer, g *global.Data) *RootCommand { + var c RootCommand + c.Globals = g + c.CmdClause = parent.Command(CommandName, "Manipulate Fastly service version domains") + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { + panic("unreachable") +} diff --git a/pkg/commands/domain/update.go b/pkg/commands/domain/update.go new file mode 100644 index 000000000..ec47d9da5 --- /dev/null +++ b/pkg/commands/domain/update.go @@ -0,0 +1,118 @@ +package domain + +import ( + "fmt" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// UpdateCommand calls the Fastly API to update domains. +type UpdateCommand struct { + argparser.Base + input fastly.UpdateDomainInput + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion + autoClone argparser.OptionalAutoClone + + NewName argparser.OptionalString + Comment argparser.OptionalString +} + +// NewUpdateCommand returns a usable command registered under the parent. +func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { + c := UpdateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("update", "Update a domain on a Fastly service version") + + // Required. + c.CmdClause.Flag("name", "Domain name").Short('n').Required().StringVar(&c.input.Name) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.autoClone.Set, + Dst: &c.autoClone.Value, + }) + c.CmdClause.Flag("comment", "A descriptive note").Action(c.Comment.Set).StringVar(&c.Comment.Value) + c.CmdClause.Flag("new-name", "New domain name").Action(c.NewName.Set).StringVar(&c.NewName.Value) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + return &c +} + +// Exec invokes the application logic for the command. +func (c *UpdateCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.autoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + c.input.ServiceID = serviceID + c.input.ServiceVersion = fastly.ToValue(serviceVersion.Number) + + // If neither arguments are provided, error with useful message. + if !c.NewName.WasSet && !c.Comment.WasSet { + return fmt.Errorf("error parsing arguments: must provide either --new-name or --comment to update domain") + } + + if c.NewName.WasSet { + c.input.NewName = &c.NewName.Value + } + if c.Comment.WasSet { + c.input.Comment = &c.Comment.Value + } + + d, err := c.Globals.APIClient.UpdateDomain(&c.input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fastly.ToValue(serviceVersion.Number), + "New Name": c.NewName.Value, + "Comment": c.Comment.Value, + }) + return err + } + + text.Success(out, "Updated domain %s (service %s version %d)", fastly.ToValue(d.Name), fastly.ToValue(d.ServiceID), fastly.ToValue(d.ServiceVersion)) + return nil +} diff --git a/pkg/commands/domain/validate.go b/pkg/commands/domain/validate.go new file mode 100644 index 000000000..3d10cc14d --- /dev/null +++ b/pkg/commands/domain/validate.go @@ -0,0 +1,187 @@ +package domain + +import ( + "fmt" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" +) + +// NewValidateCommand returns a usable command registered under the parent. +func NewValidateCommand(parent argparser.Registerer, g *global.Data) *ValidateCommand { + var c ValidateCommand + c.CmdClause = parent.Command("validate", "Checks the status of a specific domain's DNS record for a Service Version") + c.Globals = g + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.CmdClause.Flag("all", "Checks the status of all domains' DNS records for a Service Version").Short('a').BoolVar(&c.all) + c.CmdClause.Flag("name", "The name of the domain associated with this service").Short('n').Action(c.name.Set).StringVar(&c.name.Value) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + + return &c +} + +// ValidateCommand calls the Fastly API to describe an appropriate resource. +type ValidateCommand struct { + argparser.Base + + all bool + name argparser.OptionalString + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion +} + +// Exec invokes the application logic for the command. +func (c *ValidateCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + serviceVersionNumber := fastly.ToValue(serviceVersion.Number) + + if c.all { + input := c.constructInputAll(serviceID, serviceVersionNumber) + + r, err := c.Globals.APIClient.ValidateAllDomains(input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": serviceVersionNumber, + }) + return err + } + + c.printAll(out, r) + return nil + } + + input, err := c.constructInput(serviceID, serviceVersionNumber) + if err != nil { + return err + } + + r, err := c.Globals.APIClient.ValidateDomain(input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": serviceVersionNumber, + "Domain Name": c.name, + }) + return err + } + + c.print(out, r) + return nil +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *ValidateCommand) constructInput(serviceID string, serviceVersion int) (*fastly.ValidateDomainInput, error) { + var input fastly.ValidateDomainInput + + input.ServiceID = serviceID + input.ServiceVersion = serviceVersion + + if !c.name.WasSet { + return nil, errors.RemediationError{ + Inner: fmt.Errorf("error parsing arguments: must provide --name flag"), + Remediation: "Alternatively pass --all to validate all domains.", + } + } + input.Name = c.name.Value + + return &input, nil +} + +// print displays the information returned from the API. +func (c *ValidateCommand) print(out io.Writer, r *fastly.DomainValidationResult) { + fmt.Fprintf(out, "\nService ID: %s\n", fastly.ToValue(r.Metadata.ServiceID)) + fmt.Fprintf(out, "Service Version: %d\n\n", fastly.ToValue(r.Metadata.ServiceVersion)) + fmt.Fprintf(out, "Name: %s\n", fastly.ToValue(r.Metadata.Name)) + fmt.Fprintf(out, "Valid: %t\n", fastly.ToValue(r.Valid)) + + if r.CName != nil { + fmt.Fprintf(out, "CNAME: %s\n", *r.CName) + } + if r.Metadata.CreatedAt != nil { + fmt.Fprintf(out, "Created at: %s\n", r.Metadata.CreatedAt) + } + if r.Metadata.UpdatedAt != nil { + fmt.Fprintf(out, "Updated at: %s\n", r.Metadata.UpdatedAt) + } + if r.Metadata.DeletedAt != nil { + fmt.Fprintf(out, "Deleted at: %s\n", r.Metadata.DeletedAt) + } + fmt.Fprintf(out, "\n") +} + +// constructInputAll transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *ValidateCommand) constructInputAll(serviceID string, serviceVersion int) *fastly.ValidateAllDomainsInput { + var input fastly.ValidateAllDomainsInput + + input.ServiceID = serviceID + input.ServiceVersion = serviceVersion + + return &input +} + +// printAll displays all domain validation results returned from the API. +func (c *ValidateCommand) printAll(out io.Writer, rs []*fastly.DomainValidationResult) { + for i, r := range rs { + // We only need to print the Service ID/Version once. + if i == 0 { + fmt.Fprintf(out, "\nService ID: %s\n", fastly.ToValue(r.Metadata.ServiceID)) + fmt.Fprintf(out, "Service Version: %d\n\n", fastly.ToValue(r.Metadata.ServiceVersion)) + } + fmt.Fprintf(out, "Name: %s\n", fastly.ToValue(r.Metadata.Name)) + fmt.Fprintf(out, "Valid: %t\n", fastly.ToValue(r.Valid)) + + if r.CName != nil { + fmt.Fprintf(out, "CNAME: %s\n", *r.CName) + } + if r.Metadata.CreatedAt != nil { + fmt.Fprintf(out, "Created at: %s\n", r.Metadata.CreatedAt) + } + if r.Metadata.UpdatedAt != nil { + fmt.Fprintf(out, "Updated at: %s\n", r.Metadata.UpdatedAt) + } + if r.Metadata.DeletedAt != nil { + fmt.Fprintf(out, "Deleted at: %s\n", r.Metadata.DeletedAt) + } + fmt.Fprintf(out, "\n") + } +} diff --git a/pkg/commands/domainv1/common.go b/pkg/commands/domainv1/common.go new file mode 100644 index 000000000..c5c3d83b5 --- /dev/null +++ b/pkg/commands/domainv1/common.go @@ -0,0 +1,40 @@ +package domainv1 + +import ( + "fmt" + "io" + + v1 "github.com/fastly/go-fastly/v10/fastly/domains/v1" + + "github.com/fastly/cli/pkg/text" +) + +// printSummary displays the information returned from the API in a summarised +// format. +func printSummary(out io.Writer, data []v1.Data) { + t := text.NewTable(out) + t.AddHeader("FQDN", "DOMAIN ID", "SERVICE ID", "CREATED AT", "UPDATED AT") + for _, d := range data { + var sid string + if d.ServiceID != nil { + sid = *d.ServiceID + } + t.AddLine(d.FQDN, d.DomainID, sid, d.CreatedAt, d.UpdatedAt) + } + t.Print() +} + +// printSummary displays the information returned from the API in a verbose +// format. +func printVerbose(out io.Writer, data []v1.Data) { + for _, d := range data { + fmt.Fprintf(out, "FQDN: %s\n", d.FQDN) + fmt.Fprintf(out, "Domain ID: %s\n", d.DomainID) + if d.ServiceID != nil { + fmt.Fprintf(out, "Service ID: %s\n", *d.ServiceID) + } + fmt.Fprintf(out, "Created at: %s\n", d.CreatedAt) + fmt.Fprintf(out, "Updated at: %s\n", d.UpdatedAt) + fmt.Fprintf(out, "\n") + } +} diff --git a/pkg/commands/domainv1/create.go b/pkg/commands/domainv1/create.go new file mode 100644 index 000000000..9ee26e257 --- /dev/null +++ b/pkg/commands/domainv1/create.go @@ -0,0 +1,75 @@ +package domainv1 + +import ( + "errors" + "fmt" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + v1 "github.com/fastly/go-fastly/v10/fastly/domains/v1" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// CreateCommand calls the Fastly API to create domains. +type CreateCommand struct { + argparser.Base + + // Required. + fqdn string + serviceID string +} + +// NewCreateCommand returns a usable command registered under the parent. +func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { + c := CreateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("create", "Create a domain").Alias("add") + + // Optional. + c.CmdClause.Flag("fqdn", "The fully qualified domain name").Required().StringVar(&c.fqdn) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: "The service_id associated with your domain", + Dst: &c.serviceID, + Short: 's', + }) + return &c +} + +// Exec invokes the application logic for the command. +func (c *CreateCommand) Exec(_ io.Reader, out io.Writer) error { + input := &v1.CreateInput{ + FQDN: &c.fqdn, + } + if c.serviceID != "" { + input.ServiceID = &c.serviceID + } + + fc, ok := c.Globals.APIClient.(*fastly.Client) + if !ok { + return errors.New("failed to convert interface to a fastly client") + } + + d, err := v1.Create(fc, input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "FQDN": c.fqdn, + "Service ID": c.serviceID, + }) + return err + } + + serviceOutput := "" + if d.ServiceID != nil { + serviceOutput = fmt.Sprintf(", service-id: %s", *d.ServiceID) + } + + text.Success(out, "Created domain '%s' (domain-id: %s%s)", d.FQDN, d.DomainID, serviceOutput) + return nil +} diff --git a/pkg/commands/domainv1/delete.go b/pkg/commands/domainv1/delete.go new file mode 100644 index 000000000..96f43b88b --- /dev/null +++ b/pkg/commands/domainv1/delete.go @@ -0,0 +1,57 @@ +package domainv1 + +import ( + "errors" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + v1 "github.com/fastly/go-fastly/v10/fastly/domains/v1" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// DeleteCommand calls the Fastly API to delete domains. +type DeleteCommand struct { + argparser.Base + domainID string +} + +// NewDeleteCommand returns a usable command registered under the parent. +func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { + c := DeleteCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("delete", "Delete a domain").Alias("remove") + + // Required. + c.CmdClause.Flag("domain-id", "The Domain Identifier (UUID)").Required().StringVar(&c.domainID) + + return &c +} + +// Exec invokes the application logic for the command. +func (c *DeleteCommand) Exec(_ io.Reader, out io.Writer) error { + fc, ok := c.Globals.APIClient.(*fastly.Client) + if !ok { + return errors.New("failed to convert interface to a fastly client") + } + + input := &v1.DeleteInput{ + DomainID: &c.domainID, + } + + err := v1.Delete(fc, input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Domain ID": c.domainID, + }) + return err + } + + text.Success(out, "Deleted domain (domain-id: %s)", c.domainID) + return nil +} diff --git a/pkg/commands/domainv1/describe.go b/pkg/commands/domainv1/describe.go new file mode 100644 index 000000000..66bc6eca9 --- /dev/null +++ b/pkg/commands/domainv1/describe.go @@ -0,0 +1,75 @@ +package domainv1 + +import ( + "errors" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + v1 "github.com/fastly/go-fastly/v10/fastly/domains/v1" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" +) + +// DescribeCommand calls the Fastly API to describe a domain. +type DescribeCommand struct { + argparser.Base + argparser.JSONOutput + domainID string +} + +// NewDescribeCommand returns a usable command registered under the parent. +func NewDescribeCommand(parent argparser.Registerer, g *global.Data) *DescribeCommand { + c := DescribeCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("describe", "Show detailed information about a domain").Alias("get") + + // Required. + c.CmdClause.Flag("domain-id", "The Domain Identifier (UUID)").Required().StringVar(&c.domainID) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + return &c +} + +// Exec invokes the application logic for the command. +func (c *DescribeCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + fc, ok := c.Globals.APIClient.(*fastly.Client) + if !ok { + return errors.New("failed to convert interface to a fastly client") + } + + input := &v1.GetInput{ + DomainID: &c.domainID, + } + + d, err := v1.Get(fc, input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Domain ID": c.domainID, + }) + return err + } + + if ok, err := c.WriteJSON(out, d); ok { + return err + } + + if d != nil { + cl := []v1.Data{*d} + if c.Globals.Verbose() { + printVerbose(out, cl) + } else { + printSummary(out, cl) + } + } + return nil +} diff --git a/pkg/commands/domainv1/doc.go b/pkg/commands/domainv1/doc.go new file mode 100644 index 000000000..bcaed6abe --- /dev/null +++ b/pkg/commands/domainv1/doc.go @@ -0,0 +1,2 @@ +// Package domainv1 contains commands to inspect and manipulate Fastly domains. +package domainv1 diff --git a/pkg/commands/domainv1/domain_test.go b/pkg/commands/domainv1/domain_test.go new file mode 100644 index 000000000..20a6aaad3 --- /dev/null +++ b/pkg/commands/domainv1/domain_test.go @@ -0,0 +1,286 @@ +package domainv1_test + +import ( + "bytes" + "fmt" + "io" + "net/http" + "strings" + "testing" + + v1 "github.com/fastly/go-fastly/v10/fastly/domains/v1" + + root "github.com/fastly/cli/pkg/commands/domainv1" + "github.com/fastly/cli/pkg/testutil" +) + +func TestDomainV1Create(t *testing.T) { + fqdn := "www.example.com" + sid := "123" + did := "domain-id" + + scenarios := []testutil.CLIScenario{ + { + Args: "", + WantError: "error parsing arguments: required flag --fqdn not provided", + }, + { + Args: fmt.Sprintf("--fqdn %s --service-id %s", fqdn, sid), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(v1.Data{ + DomainID: did, + FQDN: fqdn, + ServiceID: &sid, + }))), + }, + }, + }, + WantOutput: fmt.Sprintf("SUCCESS: Created domain '%s' (domain-id: %s, service-id: %s)", fqdn, did, sid), + }, + { + Args: fmt.Sprintf("--fqdn %s", fqdn), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(v1.Data{ + DomainID: did, + FQDN: fqdn, + }))), + }, + }, + }, + WantOutput: fmt.Sprintf("SUCCESS: Created domain '%s' (domain-id: %s)", fqdn, did), + }, + { + Args: fmt.Sprintf("--fqdn %s", fqdn), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusBadRequest, + Status: http.StatusText(http.StatusBadRequest), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(` + { + "errors":[ + { + "title":"Invalid value for fqdn", + "detail":"fqdn has already been taken" + } + ] + } + `))), + }, + }, + }, + WantError: "400 - Bad Request", + }, + } + testutil.RunCLIScenarios(t, []string{root.CommandName, "create"}, scenarios) +} + +func TestDomainV1List(t *testing.T) { + fqdn := "www.example.com" + sid := "123" + did := "domain-id" + + resp := testutil.GenJSON(v1.Collection{ + Data: []v1.Data{ + { + DomainID: did, + FQDN: fqdn, + ServiceID: &sid, + }, + }, + }) + + scenarios := []testutil.CLIScenario{ + { + Args: "--verbose --json", + WantError: "invalid flag combination, --verbose and --json", + }, + { + Args: "--json", + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(resp)), + }, + }, + }, + WantOutput: string(resp), + }, + { + Args: "", + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusBadRequest, + Status: http.StatusText(http.StatusBadRequest), + Body: io.NopCloser(strings.NewReader(`{"error": "whoops"}`)), + }, + }, + }, + WantError: "400 - Bad Request", + }, + } + testutil.RunCLIScenarios(t, []string{root.CommandName, "list"}, scenarios) +} + +func TestDomainV1Describe(t *testing.T) { + fqdn := "www.example.com" + sid := "123" + did := "domain-id" + + resp := testutil.GenJSON(v1.Data{ + DomainID: did, + FQDN: fqdn, + ServiceID: &sid, + }) + + scenarios := []testutil.CLIScenario{ + { + Args: "", + WantError: "error parsing arguments: required flag --domain-id not provided", + }, + { + Args: fmt.Sprintf("--domain-id %s --json", did), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(resp)), + }, + }, + }, + WantOutput: string(resp), + }, + { + Args: fmt.Sprintf("--domain-id %s --json", did), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusBadRequest, + Status: http.StatusText(http.StatusBadRequest), + Body: io.NopCloser(strings.NewReader(`{"error": "whoops"}`)), + }, + }, + }, + WantError: "400 - Bad Request", + }, + } + testutil.RunCLIScenarios(t, []string{root.CommandName, "describe"}, scenarios) +} + +func TestDomainV1Update(t *testing.T) { + fqdn := "www.example.com" + sid := "123" + did := "domain-id" + + scenarios := []testutil.CLIScenario{ + { + Args: "", + WantError: "error parsing arguments: required flag --domain-id not provided", + }, + { + Args: fmt.Sprintf("--domain-id %s --service-id %s", did, sid), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(v1.Data{ + DomainID: did, + FQDN: fqdn, + ServiceID: &sid, + }))), + }, + }, + }, + WantOutput: fmt.Sprintf("SUCCESS: Updated domain '%s' (domain-id: %s, service-id: %s)", fqdn, did, sid), + }, + { + Args: fmt.Sprintf("--domain-id %s", did), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(v1.Data{ + DomainID: did, + FQDN: fqdn, + }))), + }, + }, + }, + WantOutput: fmt.Sprintf("SUCCESS: Updated domain '%s' (domain-id: %s)", fqdn, did), + }, + { + Args: fmt.Sprintf("--domain-id %s", did), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusBadRequest, + Status: http.StatusText(http.StatusBadRequest), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(` + { + "errors":[ + { + "title":"Invalid value for domain-id", + "detail":"whoops" + } + ] + } + `))), + }, + }, + }, + WantError: "400 - Bad Request", + }, + } + testutil.RunCLIScenarios(t, []string{root.CommandName, "update"}, scenarios) +} + +func TestDomainV1Delete(t *testing.T) { + did := "domain-id" + + scenarios := []testutil.CLIScenario{ + { + Args: "", + WantError: "error parsing arguments: required flag --domain-id not provided", + }, + { + Args: fmt.Sprintf("--domain-id %s", did), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusNoContent, + Status: http.StatusText(http.StatusNoContent), + }, + }, + }, + WantOutput: fmt.Sprintf("SUCCESS: Deleted domain (domain-id: %s)", did), + }, + { + Args: fmt.Sprintf("--domain-id %s", did), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusBadRequest, + Status: http.StatusText(http.StatusBadRequest), + Body: io.NopCloser(strings.NewReader(`{"error": "whoops"}`)), + }, + }, + }, + WantError: "400 - Bad Request", + }, + } + testutil.RunCLIScenarios(t, []string{root.CommandName, "delete"}, scenarios) +} diff --git a/pkg/commands/domainv1/list.go b/pkg/commands/domainv1/list.go new file mode 100644 index 000000000..a9cd78ce4 --- /dev/null +++ b/pkg/commands/domainv1/list.go @@ -0,0 +1,122 @@ +package domainv1 + +import ( + "errors" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + v1 "github.com/fastly/go-fastly/v10/fastly/domains/v1" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// ListCommand calls the Fastly API to list domains. +type ListCommand struct { + argparser.Base + argparser.JSONOutput + + cursor argparser.OptionalString + fqdn argparser.OptionalString + limit argparser.OptionalInt + serviceID argparser.OptionalString + sort argparser.OptionalString +} + +// NewListCommand returns a usable command registered under the parent. +func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { + c := ListCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("list", "List domains") + + // Optional. + c.CmdClause.Flag("cursor", "Cursor value from the next_cursor field of a previous response, used to retrieve the next page").Action(c.cursor.Set).StringVar(&c.cursor.Value) + c.CmdClause.Flag("fqdn", "Filters results by the FQDN using a fuzzy/partial match").Action(c.fqdn.Set).StringVar(&c.fqdn.Value) + c.RegisterFlagBool(c.JSONFlag()) // --json + c.CmdClause.Flag("limit", "Limit how many results are returned").Action(c.limit.Set).IntVar(&c.limit.Value) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceID.Set, + Name: argparser.FlagServiceIDName, + Description: "Filter results based on a service_id", + Dst: &c.serviceID.Value, + Short: 's', + }) + c.CmdClause.Flag("sort", "The order in which to list the results").Action(c.sort.Set).StringVar(&c.sort.Value) + return &c +} + +// Exec invokes the application logic for the command. +func (c *ListCommand) Exec(in io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + input := &v1.ListInput{} + + if c.serviceID.WasSet { + input.ServiceID = &c.serviceID.Value + } + if c.cursor.WasSet { + input.Cursor = &c.cursor.Value + } + if c.fqdn.WasSet { + input.FQDN = &c.fqdn.Value + } + if c.limit.WasSet { + input.Limit = &c.limit.Value + } + if c.sort.WasSet { + input.Sort = &c.sort.Value + } + + fc, ok := c.Globals.APIClient.(*fastly.Client) + if !ok { + return errors.New("failed to convert interface to a fastly client") + } + + for { + cl, err := v1.List(fc, input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Cursor": c.cursor.Value, + "FQDN": c.fqdn.Value, + "Limit": c.limit.Value, + "Service ID": c.serviceID.Value, + "Sort": c.sort.Value, + }) + return err + } + + if ok, err := c.WriteJSON(out, cl); ok { + // No pagination prompt w/ JSON output. + return err + } + + if c.Globals.Verbose() { + printVerbose(out, cl.Data) + } else { + printSummary(out, cl.Data) + } + + if cl != nil && cl.Meta.NextCursor != "" { + // Check if 'out' is interactive before prompting. + if !c.Globals.Flags.NonInteractive && !c.Globals.Flags.AutoYes && text.IsTTY(out) { + printNext, err := text.AskYesNo(out, "Print next page [y/N]: ", in) + if err != nil { + return err + } + if printNext { + input.Cursor = &cl.Meta.NextCursor + continue + } + } + } + + return nil + } +} diff --git a/pkg/commands/domainv1/root.go b/pkg/commands/domainv1/root.go new file mode 100644 index 000000000..945db65cc --- /dev/null +++ b/pkg/commands/domainv1/root.go @@ -0,0 +1,31 @@ +package domainv1 + +import ( + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" +) + +// RootCommand is the parent command for all subcommands in this package. +// It should be installed under the primary root command. +type RootCommand struct { + argparser.Base + // no flags +} + +// CommandName is the string to be used to invoke this command. +const CommandName = "domain-v1" + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent argparser.Registerer, g *global.Data) *RootCommand { + var c RootCommand + c.Globals = g + c.CmdClause = parent.Command(CommandName, "Manipulate Fastly domains") + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { + panic("unreachable") +} diff --git a/pkg/commands/domainv1/update.go b/pkg/commands/domainv1/update.go new file mode 100644 index 000000000..273af09bd --- /dev/null +++ b/pkg/commands/domainv1/update.go @@ -0,0 +1,71 @@ +package domainv1 + +import ( + "errors" + "fmt" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + v1 "github.com/fastly/go-fastly/v10/fastly/domains/v1" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// UpdateCommand calls the Fastly API to update domains. +type UpdateCommand struct { + argparser.Base + domainID string + serviceID string +} + +// NewUpdateCommand returns a usable command registered under the parent. +func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { + c := UpdateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("update", "Update a domain") + + // Required. + c.CmdClause.Flag("domain-id", "The Domain Identifier (UUID)").Required().StringVar(&c.domainID) + + // Optional + c.CmdClause.Flag("service-id", "The service_id associated with your domain (omit to unset)").StringVar(&c.serviceID) + + return &c +} + +// Exec invokes the application logic for the command. +func (c *UpdateCommand) Exec(_ io.Reader, out io.Writer) error { + input := &v1.UpdateInput{ + DomainID: &c.domainID, + } + if c.serviceID != "" { + input.ServiceID = &c.serviceID + } + + fc, ok := c.Globals.APIClient.(*fastly.Client) + if !ok { + return errors.New("failed to convert interface to a fastly client") + } + + d, err := v1.Update(fc, input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Domain ID": c.domainID, + "Service ID": c.serviceID, + }) + return err + } + + serviceOutput := "" + if d.ServiceID != nil { + serviceOutput = fmt.Sprintf(", service-id: %s", *d.ServiceID) + } + + text.Success(out, "Updated domain '%s' (domain-id: %s%s)", d.FQDN, d.DomainID, serviceOutput) + return nil +} diff --git a/pkg/commands/healthcheck/create.go b/pkg/commands/healthcheck/create.go new file mode 100644 index 000000000..99b58bf0b --- /dev/null +++ b/pkg/commands/healthcheck/create.go @@ -0,0 +1,162 @@ +package healthcheck + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// CreateCommand calls the Fastly API to create healthchecks. +type CreateCommand struct { + argparser.Base + + // Required. + serviceVersion argparser.OptionalServiceVersion + + // Optional. + autoClone argparser.OptionalAutoClone + checkInterval argparser.OptionalInt + comment argparser.OptionalString + expectedResponse argparser.OptionalInt + host argparser.OptionalString + httpVersion argparser.OptionalString + initial argparser.OptionalInt + method argparser.OptionalString + name argparser.OptionalString + path argparser.OptionalString + serviceName argparser.OptionalServiceNameID + threshold argparser.OptionalInt + timeout argparser.OptionalInt + window argparser.OptionalInt +} + +// NewCreateCommand returns a usable command registered under the parent. +func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { + c := CreateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("create", "Create a healthcheck on a Fastly service version").Alias("add") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.autoClone.Set, + Dst: &c.autoClone.Value, + }) + c.CmdClause.Flag("check-interval", "How often to run the healthcheck in milliseconds").Action(c.checkInterval.Set).IntVar(&c.checkInterval.Value) + c.CmdClause.Flag("comment", "A descriptive note").Action(c.comment.Set).StringVar(&c.comment.Value) + c.CmdClause.Flag("expected-response", "The status code expected from the host").Action(c.expectedResponse.Set).IntVar(&c.expectedResponse.Value) + c.CmdClause.Flag("host", "Which host to check").Action(c.host.Set).StringVar(&c.host.Value) + c.CmdClause.Flag("http-version", "Whether to use version 1.0 or 1.1 HTTP").Action(c.httpVersion.Set).StringVar(&c.httpVersion.Value) + c.CmdClause.Flag("initial", "When loading a config, the initial number of probes to be seen as OK").Action(c.initial.Set).IntVar(&c.initial.Value) + c.CmdClause.Flag("method", "Which HTTP method to use").Action(c.method.Set).StringVar(&c.method.Value) + c.CmdClause.Flag("name", "Healthcheck name").Short('n').Action(c.name.Set).StringVar(&c.name.Value) + c.CmdClause.Flag("path", "The path to check").Action(c.path.Set).StringVar(&c.path.Value) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + c.CmdClause.Flag("threshold", "How many healthchecks must succeed to be considered healthy").Action(c.threshold.Set).IntVar(&c.threshold.Value) + c.CmdClause.Flag("timeout", "Timeout in milliseconds").Action(c.timeout.Set).IntVar(&c.timeout.Value) + c.CmdClause.Flag("window", "The number of most recent healthcheck queries to keep for this healthcheck").Action(c.window.Set).IntVar(&c.window.Value) + return &c +} + +// Exec invokes the application logic for the command. +func (c *CreateCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.autoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + input := fastly.CreateHealthCheckInput{ + ServiceID: serviceID, + ServiceVersion: fastly.ToValue(serviceVersion.Number), + } + + if c.name.WasSet { + input.Name = &c.name.Value + } + if c.comment.WasSet { + input.Comment = &c.comment.Value + } + if c.method.WasSet { + input.Method = &c.method.Value + } + if c.host.WasSet { + input.Host = &c.host.Value + } + if c.path.WasSet { + input.Path = &c.path.Value + } + if c.httpVersion.WasSet { + input.HTTPVersion = &c.httpVersion.Value + } + if c.timeout.WasSet { + input.Timeout = &c.timeout.Value + } + if c.checkInterval.WasSet { + input.CheckInterval = &c.checkInterval.Value + } + if c.expectedResponse.WasSet { + input.ExpectedResponse = &c.expectedResponse.Value + } + if c.window.WasSet { + input.Window = &c.window.Value + } + if c.threshold.WasSet { + input.Threshold = &c.threshold.Value + } + if c.initial.WasSet { + input.Initial = &c.initial.Value + } + + h, err := c.Globals.APIClient.CreateHealthCheck(&input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": serviceVersion.Number, + }) + return err + } + + text.Success(out, "Created healthcheck %s (service %s version %d)", fastly.ToValue(h.Name), fastly.ToValue(h.ServiceID), fastly.ToValue(h.ServiceVersion)) + return nil +} diff --git a/pkg/commands/healthcheck/delete.go b/pkg/commands/healthcheck/delete.go new file mode 100644 index 000000000..44264454e --- /dev/null +++ b/pkg/commands/healthcheck/delete.go @@ -0,0 +1,97 @@ +package healthcheck + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// DeleteCommand calls the Fastly API to delete healthchecks. +type DeleteCommand struct { + argparser.Base + Input fastly.DeleteHealthCheckInput + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion + autoClone argparser.OptionalAutoClone +} + +// NewDeleteCommand returns a usable command registered under the parent. +func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { + c := DeleteCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("delete", "Delete a healthcheck on a Fastly service version").Alias("remove") + + // Required. + c.CmdClause.Flag("name", "Healthcheck name").Short('n').Required().StringVar(&c.Input.Name) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.autoClone.Set, + Dst: &c.autoClone.Value, + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + return &c +} + +// Exec invokes the application logic for the command. +func (c *DeleteCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.autoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + c.Input.ServiceID = serviceID + c.Input.ServiceVersion = fastly.ToValue(serviceVersion.Number) + + if err := c.Globals.APIClient.DeleteHealthCheck(&c.Input); err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fastly.ToValue(serviceVersion.Number), + }) + return err + } + + text.Success(out, "Deleted healthcheck %s (service %s version %d)", c.Input.Name, c.Input.ServiceID, c.Input.ServiceVersion) + return nil +} diff --git a/pkg/commands/healthcheck/describe.go b/pkg/commands/healthcheck/describe.go new file mode 100644 index 000000000..1a1d5e662 --- /dev/null +++ b/pkg/commands/healthcheck/describe.go @@ -0,0 +1,105 @@ +package healthcheck + +import ( + "fmt" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// DescribeCommand calls the Fastly API to describe a healthcheck. +type DescribeCommand struct { + argparser.Base + argparser.JSONOutput + + Input fastly.GetHealthCheckInput + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion +} + +// NewDescribeCommand returns a usable command registered under the parent. +func NewDescribeCommand(parent argparser.Registerer, g *global.Data) *DescribeCommand { + c := DescribeCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("describe", "Show detailed information about a healthcheck on a Fastly service version").Alias("get") + + // Required. + c.CmdClause.Flag("name", "Name of healthcheck").Short('n').Required().StringVar(&c.Input.Name) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + return &c +} + +// Exec invokes the application logic for the command. +func (c *DescribeCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fsterr.ServiceVersion(serviceVersion), + }) + return err + } + + c.Input.ServiceID = serviceID + c.Input.ServiceVersion = fastly.ToValue(serviceVersion.Number) + + o, err := c.Globals.APIClient.GetHealthCheck(&c.Input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fastly.ToValue(serviceVersion.Number), + }) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + if !c.Globals.Verbose() { + fmt.Fprintf(out, "\nService ID: %s\n", fastly.ToValue(o.ServiceID)) + } + fmt.Fprintf(out, "Version: %d\n", fastly.ToValue(o.ServiceVersion)) + text.PrintHealthCheck(out, "", o) + + return nil +} diff --git a/pkg/healthcheck/doc.go b/pkg/commands/healthcheck/doc.go similarity index 100% rename from pkg/healthcheck/doc.go rename to pkg/commands/healthcheck/doc.go diff --git a/pkg/commands/healthcheck/healthcheck_test.go b/pkg/commands/healthcheck/healthcheck_test.go new file mode 100644 index 000000000..19d27439b --- /dev/null +++ b/pkg/commands/healthcheck/healthcheck_test.go @@ -0,0 +1,423 @@ +package healthcheck_test + +import ( + "bytes" + "errors" + "io" + "net/http" + "strings" + "testing" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/app" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" +) + +func TestHealthCheckCreate(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("healthcheck create --version 1"), + wantError: "error reading service: no service ID found", + }, + { + args: args("healthcheck create --service-id 123 --version 1 --name www.test.com --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + CreateHealthCheckFn: createHealthCheckError, + }, + wantError: errTest.Error(), + }, + // NOTE: Added --timeout flag to validate that a nil pointer dereference is + // not triggered at runtime when parsing the arguments. + { + args: args("healthcheck create --service-id 123 --version 1 --name www.test.com --autoclone --timeout 10"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + CreateHealthCheckFn: createHealthCheckOK, + }, + wantOutput: "Created healthcheck www.test.com (service 123 version 4)", + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertStringContains(t, stdout.String(), testcase.wantOutput) + }) + } +} + +func TestHealthCheckList(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("healthcheck list --service-id 123 --version 1"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListHealthChecksFn: listHealthChecksOK, + }, + wantOutput: listHealthChecksShortOutput, + }, + { + args: args("healthcheck list --service-id 123 --version 1 --verbose"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListHealthChecksFn: listHealthChecksOK, + }, + wantOutput: listHealthChecksVerboseOutput, + }, + { + args: args("healthcheck list --service-id 123 --version 1 -v"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListHealthChecksFn: listHealthChecksOK, + }, + wantOutput: listHealthChecksVerboseOutput, + }, + { + args: args("healthcheck --verbose list --service-id 123 --version 1"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListHealthChecksFn: listHealthChecksOK, + }, + wantOutput: listHealthChecksVerboseOutput, + }, + { + args: args("-v healthcheck list --service-id 123 --version 1"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListHealthChecksFn: listHealthChecksOK, + }, + wantOutput: listHealthChecksVerboseOutput, + }, + { + args: args("healthcheck list --service-id 123 --version 1"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListHealthChecksFn: listHealthChecksError, + }, + wantError: errTest.Error(), + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertString(t, testcase.wantOutput, stdout.String()) + }) + } +} + +func TestHealthCheckDescribe(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("healthcheck describe --service-id 123 --version 1"), + wantError: "error parsing arguments: required flag --name not provided", + }, + { + args: args("healthcheck describe --service-id 123 --version 1 --name www.test.com"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + GetHealthCheckFn: getHealthCheckError, + }, + wantError: errTest.Error(), + }, + { + args: args("healthcheck describe --service-id 123 --version 1 --name www.test.com"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + GetHealthCheckFn: getHealthCheckOK, + }, + wantOutput: describeHealthCheckOutput, + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertString(t, testcase.wantOutput, stdout.String()) + }) + } +} + +func TestHealthCheckUpdate(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("healthcheck update --service-id 123 --version 1 --new-name www.test.com --comment "), + wantError: "error parsing arguments: required flag --name not provided", + }, + { + args: args("healthcheck update --service-id 123 --version 1 --name www.test.com --new-name www.example.com --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + UpdateHealthCheckFn: updateHealthCheckOK, + }, + }, + { + args: args("healthcheck update --service-id 123 --version 1 --name www.test.com --new-name www.example.com --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + UpdateHealthCheckFn: updateHealthCheckError, + }, + wantError: errTest.Error(), + }, + { + args: args("healthcheck update --service-id 123 --version 1 --name www.test.com --new-name www.example.com --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + UpdateHealthCheckFn: updateHealthCheckOK, + }, + wantOutput: "Updated healthcheck www.example.com (service 123 version 4)", + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertStringContains(t, stdout.String(), testcase.wantOutput) + }) + } +} + +func TestHealthCheckDelete(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("healthcheck delete --service-id 123 --version 1"), + wantError: "error parsing arguments: required flag --name not provided", + }, + { + args: args("healthcheck delete --service-id 123 --version 1 --name www.test.com --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + DeleteHealthCheckFn: deleteHealthCheckError, + }, + wantError: errTest.Error(), + }, + { + args: args("healthcheck delete --service-id 123 --version 1 --name www.test.com --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + DeleteHealthCheckFn: deleteHealthCheckOK, + }, + wantOutput: "Deleted healthcheck www.test.com (service 123 version 4)", + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertStringContains(t, stdout.String(), testcase.wantOutput) + }) + } +} + +var errTest = errors.New("fixture error") + +func createHealthCheckOK(i *fastly.CreateHealthCheckInput) (*fastly.HealthCheck, error) { + return &fastly.HealthCheck{ + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: i.Name, + Host: fastly.ToPointer("www.test.com"), + Path: fastly.ToPointer("/health"), + }, nil +} + +func createHealthCheckError(_ *fastly.CreateHealthCheckInput) (*fastly.HealthCheck, error) { + return nil, errTest +} + +func listHealthChecksOK(i *fastly.ListHealthChecksInput) ([]*fastly.HealthCheck, error) { + return []*fastly.HealthCheck{ + { + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("test"), + Comment: fastly.ToPointer("test"), + Method: fastly.ToPointer(http.MethodHead), + Host: fastly.ToPointer("www.test.com"), + Path: fastly.ToPointer("/health"), + }, + { + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("example"), + Comment: fastly.ToPointer("example"), + Method: fastly.ToPointer(http.MethodHead), + Host: fastly.ToPointer("www.example.com"), + Path: fastly.ToPointer("/health"), + }, + }, nil +} + +func listHealthChecksError(_ *fastly.ListHealthChecksInput) ([]*fastly.HealthCheck, error) { + return nil, errTest +} + +var listHealthChecksShortOutput = strings.TrimSpace(` +SERVICE VERSION NAME METHOD HOST PATH +123 1 test HEAD www.test.com /health +123 1 example HEAD www.example.com /health +`) + "\n" + +var listHealthChecksVerboseOutput = strings.Join([]string{ + "Fastly API endpoint: https://api.fastly.com", + "Fastly API token provided via config file (profile: user)", + "", + "Service ID (via --service-id): 123", + "", + "Version: 1", + " Healthcheck 1/2", + " Name: test", + " Comment: test", + " Method: HEAD", + " Host: www.test.com", + " Path: /health", + " HTTP version: ", + " Timeout: 0", + " Check interval: 0", + " Expected response: 0", + " Window: 0", + " Threshold: 0", + " Initial: 0", + " Healthcheck 2/2", + " Name: example", + " Comment: example", + " Method: HEAD", + " Host: www.example.com", + " Path: /health", + " HTTP version: ", + " Timeout: 0", + " Check interval: 0", + " Expected response: 0", + " Window: 0", + " Threshold: 0", + " Initial: 0", +}, "\n") + "\n\n" + +func getHealthCheckOK(i *fastly.GetHealthCheckInput) (*fastly.HealthCheck, error) { + return &fastly.HealthCheck{ + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("test"), + Method: fastly.ToPointer(http.MethodHead), + Host: fastly.ToPointer("www.test.com"), + Path: fastly.ToPointer("/healthcheck"), + Comment: fastly.ToPointer("test"), + }, nil +} + +func getHealthCheckError(_ *fastly.GetHealthCheckInput) (*fastly.HealthCheck, error) { + return nil, errTest +} + +var describeHealthCheckOutput = "\n" + strings.Join([]string{ + "Service ID: 123", + "Version: 1", + "Name: test", + "Comment: test", + "Method: HEAD", + "Host: www.test.com", + "Path: /healthcheck", + "HTTP version: ", + "Timeout: 0", + "Check interval: 0", + "Expected response: 0", + "Window: 0", + "Threshold: 0", + "Initial: 0", +}, "\n") + "\n" + +func updateHealthCheckOK(i *fastly.UpdateHealthCheckInput) (*fastly.HealthCheck, error) { + return &fastly.HealthCheck{ + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: i.NewName, + }, nil +} + +func updateHealthCheckError(_ *fastly.UpdateHealthCheckInput) (*fastly.HealthCheck, error) { + return nil, errTest +} + +func deleteHealthCheckOK(_ *fastly.DeleteHealthCheckInput) error { + return nil +} + +func deleteHealthCheckError(_ *fastly.DeleteHealthCheckInput) error { + return errTest +} diff --git a/pkg/commands/healthcheck/list.go b/pkg/commands/healthcheck/list.go new file mode 100644 index 000000000..b926deaf7 --- /dev/null +++ b/pkg/commands/healthcheck/list.go @@ -0,0 +1,122 @@ +package healthcheck + +import ( + "fmt" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// ListCommand calls the Fastly API to list healthchecks. +type ListCommand struct { + argparser.Base + argparser.JSONOutput + + Input fastly.ListHealthChecksInput + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion +} + +// NewListCommand returns a usable command registered under the parent. +func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { + c := ListCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("list", "List healthchecks on a Fastly service version") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + return &c +} + +// Exec invokes the application logic for the command. +func (c *ListCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fsterr.ServiceVersion(serviceVersion), + }) + return err + } + + c.Input.ServiceID = serviceID + c.Input.ServiceVersion = fastly.ToValue(serviceVersion.Number) + + o, err := c.Globals.APIClient.ListHealthChecks(&c.Input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fastly.ToValue(serviceVersion.Number), + }) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + if !c.Globals.Verbose() { + tw := text.NewTable(out) + tw.AddHeader("SERVICE", "VERSION", "NAME", "METHOD", "HOST", "PATH") + for _, hc := range o { + tw.AddLine( + fastly.ToValue(hc.ServiceID), + fastly.ToValue(hc.ServiceVersion), + fastly.ToValue(hc.Name), + fastly.ToValue(hc.Method), + fastly.ToValue(hc.Host), + fastly.ToValue(hc.Path), + ) + } + tw.Print() + return nil + } + + fmt.Fprintf(out, "Version: %d\n", c.Input.ServiceVersion) + for i, hc := range o { + fmt.Fprintf(out, "\tHealthcheck %d/%d\n", i+1, len(o)) + text.PrintHealthCheck(out, "\t\t", hc) + } + fmt.Fprintln(out) + + return nil +} diff --git a/pkg/commands/healthcheck/root.go b/pkg/commands/healthcheck/root.go new file mode 100644 index 000000000..b4683322a --- /dev/null +++ b/pkg/commands/healthcheck/root.go @@ -0,0 +1,31 @@ +package healthcheck + +import ( + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" +) + +// RootCommand is the parent command for all subcommands in this package. +// It should be installed under the primary root command. +type RootCommand struct { + argparser.Base + // no flags +} + +// CommandName is the string to be used to invoke this command. +const CommandName = "healthcheck" + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent argparser.Registerer, g *global.Data) *RootCommand { + var c RootCommand + c.Globals = g + c.CmdClause = parent.Command(CommandName, "Manipulate Fastly service version healthchecks") + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { + panic("unreachable") +} diff --git a/pkg/commands/healthcheck/update.go b/pkg/commands/healthcheck/update.go new file mode 100644 index 000000000..2b0e88d38 --- /dev/null +++ b/pkg/commands/healthcheck/update.go @@ -0,0 +1,165 @@ +package healthcheck + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// UpdateCommand calls the Fastly API to update healthchecks. +type UpdateCommand struct { + argparser.Base + input fastly.UpdateHealthCheckInput + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion + autoClone argparser.OptionalAutoClone + + NewName argparser.OptionalString + Comment argparser.OptionalString + Method argparser.OptionalString + Host argparser.OptionalString + Path argparser.OptionalString + HTTPVersion argparser.OptionalString + Timeout argparser.OptionalInt + CheckInterval argparser.OptionalInt + ExpectedResponse argparser.OptionalInt + Window argparser.OptionalInt + Threshold argparser.OptionalInt + Initial argparser.OptionalInt +} + +// NewUpdateCommand returns a usable command registered under the parent. +func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { + c := UpdateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("update", "Update a healthcheck on a Fastly service version") + + // Required. + c.CmdClause.Flag("name", "Healthcheck name").Short('n').Required().StringVar(&c.input.Name) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.autoClone.Set, + Dst: &c.autoClone.Value, + }) + c.CmdClause.Flag("check-interval", "How often to run the healthcheck in milliseconds").Action(c.CheckInterval.Set).IntVar(&c.CheckInterval.Value) + c.CmdClause.Flag("comment", "A descriptive note").Action(c.Comment.Set).StringVar(&c.Comment.Value) + c.CmdClause.Flag("expected-response", "The status code expected from the host").Action(c.ExpectedResponse.Set).IntVar(&c.ExpectedResponse.Value) + c.CmdClause.Flag("host", "Which host to check").Action(c.Host.Set).StringVar(&c.Host.Value) + c.CmdClause.Flag("http-version", "Whether to use version 1.0 or 1.1 HTTP").Action(c.HTTPVersion.Set).StringVar(&c.HTTPVersion.Value) + c.CmdClause.Flag("initial", "When loading a config, the initial number of probes to be seen as OK").Action(c.Initial.Set).IntVar(&c.Initial.Value) + c.CmdClause.Flag("method", "Which HTTP method to use").Action(c.Method.Set).StringVar(&c.Method.Value) + c.CmdClause.Flag("new-name", "Healthcheck name").Action(c.NewName.Set).StringVar(&c.NewName.Value) + c.CmdClause.Flag("path", "The path to check").Action(c.Path.Set).StringVar(&c.Path.Value) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + c.CmdClause.Flag("threshold", "How many healthchecks must succeed to be considered healthy").Action(c.Threshold.Set).IntVar(&c.Threshold.Value) + c.CmdClause.Flag("timeout", "Timeout in milliseconds").Action(c.Timeout.Set).IntVar(&c.Timeout.Value) + c.CmdClause.Flag("window", "The number of most recent healthcheck queries to keep for this healthcheck").Action(c.Window.Set).IntVar(&c.Window.Value) + return &c +} + +// Exec invokes the application logic for the command. +func (c *UpdateCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.autoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + c.input.ServiceID = serviceID + c.input.ServiceVersion = fastly.ToValue(serviceVersion.Number) + + if c.NewName.WasSet { + c.input.NewName = &c.NewName.Value + } + if c.Comment.WasSet { + c.input.Comment = &c.Comment.Value + } + if c.Method.WasSet { + c.input.Method = &c.Method.Value + } + if c.Host.WasSet { + c.input.Host = &c.Host.Value + } + if c.Path.WasSet { + c.input.Path = &c.Path.Value + } + if c.HTTPVersion.WasSet { + c.input.HTTPVersion = &c.HTTPVersion.Value + } + if c.Timeout.WasSet { + c.input.Timeout = &c.Timeout.Value + } + if c.CheckInterval.WasSet { + c.input.CheckInterval = &c.CheckInterval.Value + } + if c.ExpectedResponse.WasSet { + c.input.ExpectedResponse = &c.ExpectedResponse.Value + } + if c.Window.WasSet { + c.input.Window = &c.Window.Value + } + if c.Threshold.WasSet { + c.input.Threshold = &c.Threshold.Value + } + if c.Initial.WasSet { + c.input.Initial = &c.Initial.Value + } + + h, err := c.Globals.APIClient.UpdateHealthCheck(&c.input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fastly.ToValue(serviceVersion.Number), + }) + return err + } + + text.Success(out, + "Updated healthcheck %s (service %s version %d)", + fastly.ToValue(h.Name), + fastly.ToValue(h.ServiceID), + fastly.ToValue(h.ServiceVersion), + ) + return nil +} diff --git a/pkg/commands/install/doc.go b/pkg/commands/install/doc.go new file mode 100644 index 000000000..358a1b890 --- /dev/null +++ b/pkg/commands/install/doc.go @@ -0,0 +1,2 @@ +// Package install contains functions for installing a specific CLI version. +package install diff --git a/pkg/commands/install/root.go b/pkg/commands/install/root.go new file mode 100644 index 000000000..4cc5450cc --- /dev/null +++ b/pkg/commands/install/root.go @@ -0,0 +1,121 @@ +package install + +import ( + "fmt" + "io" + "os" + "path/filepath" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/filesystem" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// RootCommand is the parent command for all subcommands in this package. +// It should be installed under the primary root command. +type RootCommand struct { + argparser.Base + + versionToInstall string +} + +// CommandName is the string to be used to invoke this command. +const CommandName = "install" + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent argparser.Registerer, g *global.Data) *RootCommand { + var c RootCommand + c.Globals = g + c.CmdClause = parent.Command(CommandName, "Install the specified version of the CLI") + c.CmdClause.Arg("version", "CLI release version to install (e.g. 10.8.0)").Required().StringVar(&c.versionToInstall) + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, out io.Writer) error { + spinner, err := text.NewSpinner(out) + if err != nil { + return err + } + + var downloadedBin string + err = spinner.Process(fmt.Sprintf("Fetching release %s", c.versionToInstall), func(_ *text.SpinnerWrapper) error { + downloadedBin, err = c.Globals.Versioners.CLI.DownloadVersion(c.versionToInstall) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "CLI version to install": c.versionToInstall, + }) + return fmt.Errorf("error downloading release version %s: %w", c.versionToInstall, err) + } + return nil + }) + if err != nil { + return err + } + defer os.RemoveAll(downloadedBin) + + var currentBin string + err = spinner.Process("Replacing binary", func(_ *text.SpinnerWrapper) error { + execPath, err := os.Executable() + if err != nil { + c.Globals.ErrLog.Add(err) + return fmt.Errorf("error determining executable path: %w", err) + } + + currentBin, err = filepath.Abs(execPath) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Executable path": execPath, + }) + return fmt.Errorf("error determining absolute target path: %w", err) + } + + // Windows does not permit replacing a running executable, however it will + // permit it if you first move the original executable. So we first move the + // running executable to a new location, then we move the executable that we + // downloaded to the same location as the original. + // I've also tested this approach on nix systems and it works fine. + // + // Reference: + // https://github.com/golang/go/issues/21997#issuecomment-331744930 + + backup := currentBin + ".bak" + if err := os.Rename(currentBin, backup); err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Executable (source)": downloadedBin, + "Executable (destination)": currentBin, + }) + return fmt.Errorf("error moving the current executable: %w", err) + } + + if err = os.Remove(backup); err != nil { + c.Globals.ErrLog.Add(err) + } + + // Move the downloaded binary to the same location as the current executable. + if err := os.Rename(downloadedBin, currentBin); err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Executable (source)": downloadedBin, + "Executable (destination)": currentBin, + }) + renameErr := err + + // Failing that we'll try to io.Copy downloaded binary to the current binary. + if err := filesystem.CopyFile(downloadedBin, currentBin); err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Executable (source)": downloadedBin, + "Executable (destination)": currentBin, + }) + return fmt.Errorf("error 'copying' latest binary in place: %w (following an error 'moving': %w)", err, renameErr) + } + } + return nil + }) + if err != nil { + return err + } + + text.Success(out, "\nInstalled version %s.", c.versionToInstall) + return nil +} diff --git a/pkg/commands/ip/doc.go b/pkg/commands/ip/doc.go new file mode 100644 index 000000000..6ec32f05a --- /dev/null +++ b/pkg/commands/ip/doc.go @@ -0,0 +1,2 @@ +// Package ip contains commands to inspect and manipulate Fastly IPs. +package ip diff --git a/pkg/commands/ip/ip_test.go b/pkg/commands/ip/ip_test.go new file mode 100644 index 000000000..9b2bea8de --- /dev/null +++ b/pkg/commands/ip/ip_test.go @@ -0,0 +1,31 @@ +package ip_test + +import ( + "testing" + + "github.com/fastly/go-fastly/v10/fastly" + + root "github.com/fastly/cli/pkg/commands/ip" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" +) + +func TestAllIPs(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate listing IP addresses", + API: mock.API{ + AllIPsFn: func() (v4, v6 fastly.IPAddrs, err error) { + return []string{ + "00.123.45.6/78", + }, []string{ + "0a12:3b45::/67", + }, nil + }, + }, + WantOutput: "\nIPv4\n\t00.123.45.6/78\n\nIPv6\n\t0a12:3b45::/67\n", + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName}, scenarios) +} diff --git a/pkg/commands/ip/root.go b/pkg/commands/ip/root.go new file mode 100644 index 000000000..3f62a11d3 --- /dev/null +++ b/pkg/commands/ip/root.go @@ -0,0 +1,49 @@ +package ip + +import ( + "fmt" + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// RootCommand is the parent command for all subcommands in this package. +// It should be installed under the primary root command. +type RootCommand struct { + argparser.Base +} + +// CommandName is the string to be used to invoke this command. +const CommandName = "ip-list" + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent argparser.Registerer, g *global.Data) *RootCommand { + var c RootCommand + c.Globals = g + c.CmdClause = parent.Command(CommandName, "List Fastly's public IPs") + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, out io.Writer) error { + ipv4, ipv6, err := c.Globals.APIClient.AllIPs() + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + // TODO: Implement --json support. + + text.Break(out) + fmt.Fprintf(out, "%s\n", text.Bold("IPv4")) + for _, ip := range ipv4 { + fmt.Fprintf(out, "\t%s\n", ip) + } + fmt.Fprintf(out, "\n%s\n", text.Bold("IPv6")) + for _, ip := range ipv6 { + fmt.Fprintf(out, "\t%s\n", ip) + } + return nil +} diff --git a/pkg/commands/kvstore/create.go b/pkg/commands/kvstore/create.go new file mode 100644 index 000000000..e8550aab2 --- /dev/null +++ b/pkg/commands/kvstore/create.go @@ -0,0 +1,59 @@ +package kvstore + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// CreateCommand calls the Fastly API to create an kv store. +type CreateCommand struct { + argparser.Base + argparser.JSONOutput + + Input fastly.CreateKVStoreInput +} + +// locations is a list of supported regional location options. +var locations = []string{"US", "EU", "ASIA", "AUS"} + +// NewCreateCommand returns a usable command registered under the parent. +func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { + c := CreateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + + c.CmdClause = parent.Command("create", "Create a KV Store") + c.RegisterFlagBool(c.JSONFlag()) // --json + c.CmdClause.Flag("location", "Regional location of KV Store").Short('l').HintOptions(locations...).EnumVar(&c.Input.Location, locations...) + c.CmdClause.Flag("name", "Name of KV Store").Short('n').Required().StringVar(&c.Input.Name) + + return &c +} + +// Exec invokes the application logic for the command. +func (c *CreateCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + o, err := c.Globals.APIClient.CreateKVStore(&c.Input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + text.Success(out, "Created KV Store '%s' (%s)", o.Name, o.StoreID) + return nil +} diff --git a/pkg/commands/kvstore/delete.go b/pkg/commands/kvstore/delete.go new file mode 100644 index 000000000..bedaf675b --- /dev/null +++ b/pkg/commands/kvstore/delete.go @@ -0,0 +1,101 @@ +package kvstore + +import ( + "fmt" + "io" + "strconv" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/kvstoreentry" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// DeleteCommand calls the Fastly API to delete an kv store. +type DeleteCommand struct { + argparser.Base + argparser.JSONOutput + + deleteAll bool + maxErrors int + poolSize int + Input fastly.DeleteKVStoreInput +} + +// NewDeleteCommand returns a usable command registered under the parent. +func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { + c := DeleteCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("delete", "Delete a KV Store") + + // Required. + c.CmdClause.Flag("store-id", "Store ID").Short('s').Required().StringVar(&c.Input.StoreID) + + // Optional. + c.CmdClause.Flag("all", "Delete all entries within the store").Short('a').BoolVar(&c.deleteAll) + c.CmdClause.Flag("concurrency", "The thread pool size (ignored when set without the --all flag)").Default(strconv.Itoa(kvstoreentry.DeleteKeysPoolSize)).Short('r').IntVar(&c.poolSize) + c.RegisterFlagBool(c.JSONFlag()) // --json + c.CmdClause.Flag("max-errors", "The number of errors to accept before stopping (ignored when set without the --all flag)").Default(strconv.Itoa(kvstoreentry.DeleteKeysMaxErrors)).Short('m').IntVar(&c.maxErrors) + return &c +} + +// Exec invokes the application logic for the command. +func (c *DeleteCommand) Exec(in io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + if c.deleteAll { + if !c.Globals.Flags.AutoYes && !c.Globals.Flags.NonInteractive { + text.Warning(out, "This will delete ALL entries from your store!\n\n") + cont, err := text.AskYesNo(out, "Are you sure you want to continue? [y/N]: ", in) + if err != nil { + return err + } + if !cont { + return nil + } + text.Break(out) + } + dc := kvstoreentry.DeleteCommand{ + Base: argparser.Base{ + Globals: c.Globals, + }, + DeleteAll: c.deleteAll, + MaxErrors: c.maxErrors, + PoolSize: c.poolSize, + StoreID: c.Input.StoreID, + } + if err := dc.DeleteAllKeys(out); err != nil { + return err + } + text.Break(out) + } + + err := c.Globals.APIClient.DeleteKVStore(&c.Input) + if err != nil { + c.Globals.ErrLog.Add(err) + return fmt.Errorf("failed to delete KV store: %w", err) + } + + if c.JSONOutput.Enabled { + o := struct { + ID string `json:"id"` + Deleted bool `json:"deleted"` + }{ + c.Input.StoreID, + true, + } + _, err := c.WriteJSON(out, o) + return err + } + + text.Success(out, "Deleted KV Store '%s'", c.Input.StoreID) + return nil +} diff --git a/pkg/commands/kvstore/describe.go b/pkg/commands/kvstore/describe.go new file mode 100644 index 000000000..00da05838 --- /dev/null +++ b/pkg/commands/kvstore/describe.go @@ -0,0 +1,58 @@ +package kvstore + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// DescribeCommand calls the Fastly API to fetch the value of a key from an kv store. +type DescribeCommand struct { + argparser.Base + argparser.JSONOutput + + Input fastly.GetKVStoreInput +} + +// NewDescribeCommand returns a usable command registered under the parent. +func NewDescribeCommand(parent argparser.Registerer, g *global.Data) *DescribeCommand { + c := DescribeCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("describe", "Describe a KV Store").Alias("get") + + // Required. + c.CmdClause.Flag("store-id", "Store ID").Short('s').Required().StringVar(&c.Input.StoreID) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + + return &c +} + +// Exec invokes the application logic for the command. +func (c *DescribeCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + o, err := c.Globals.APIClient.GetKVStore(&c.Input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + text.PrintKVStore(out, "", o) + return nil +} diff --git a/pkg/commands/kvstore/doc.go b/pkg/commands/kvstore/doc.go new file mode 100644 index 000000000..bad798725 --- /dev/null +++ b/pkg/commands/kvstore/doc.go @@ -0,0 +1,3 @@ +// Package kvstore contains commands to inspect and manipulate Fastly edge +// kv stores. +package kvstore diff --git a/pkg/commands/kvstore/kvstore_test.go b/pkg/commands/kvstore/kvstore_test.go new file mode 100644 index 000000000..421ddf6dd --- /dev/null +++ b/pkg/commands/kvstore/kvstore_test.go @@ -0,0 +1,291 @@ +package kvstore_test + +import ( + "bytes" + "errors" + "fmt" + "testing" + "time" + + "github.com/fastly/go-fastly/v10/fastly" + + root "github.com/fastly/cli/pkg/commands/kvstore" + fstfmt "github.com/fastly/cli/pkg/fmt" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" + "github.com/fastly/cli/pkg/text" +) + +func TestCreateStoreCommand(t *testing.T) { + const ( + storeName = "test123" + storeLocation = "EU" + storeID = "store-id-123" + ) + now := time.Now() + + scenarios := []testutil.CLIScenario{ + { + WantError: "error parsing arguments: required flag --name not provided", + }, + { + Args: fmt.Sprintf("--name %s", storeName), + API: mock.API{ + CreateKVStoreFn: func(_ *fastly.CreateKVStoreInput) (*fastly.KVStore, error) { + return nil, errors.New("invalid request") + }, + }, + WantError: "invalid request", + }, + { + Args: fmt.Sprintf("--name %s", storeName), + API: mock.API{ + CreateKVStoreFn: func(i *fastly.CreateKVStoreInput) (*fastly.KVStore, error) { + return &fastly.KVStore{ + StoreID: storeID, + Name: i.Name, + }, nil + }, + }, + WantOutput: fstfmt.Success("Created KV Store '%s' (%s)", storeName, storeID), + }, + { + Args: fmt.Sprintf("--name %s --json", storeName), + API: mock.API{ + CreateKVStoreFn: func(i *fastly.CreateKVStoreInput) (*fastly.KVStore, error) { + return &fastly.KVStore{ + StoreID: storeID, + Name: i.Name, + CreatedAt: &now, + UpdatedAt: &now, + }, nil + }, + }, + WantOutput: fstfmt.EncodeJSON(&fastly.KVStore{ + StoreID: storeID, + Name: storeName, + CreatedAt: &now, + UpdatedAt: &now, + }), + }, + { + // NOTE: The following tests only validate support for the --location flag. + // Location/region indicators are not exposed for us to validate. + Args: fmt.Sprintf("--name %s --location %s", storeName, storeLocation), + API: mock.API{ + CreateKVStoreFn: func(i *fastly.CreateKVStoreInput) (*fastly.KVStore, error) { + return &fastly.KVStore{ + StoreID: storeID, + Name: i.Name, + }, nil + }, + }, + WantOutput: fstfmt.Success("Created KV Store '%s' (%s)", storeName, storeID), + }, + { + // NOTE: The following tests only validate support for the --location flag. + // Location/region indicators are not exposed for us to validate. + Args: fmt.Sprintf("--name %s --location %s --json", storeName, storeLocation), + API: mock.API{ + CreateKVStoreFn: func(i *fastly.CreateKVStoreInput) (*fastly.KVStore, error) { + return &fastly.KVStore{ + StoreID: storeID, + Name: i.Name, + CreatedAt: &now, + UpdatedAt: &now, + }, nil + }, + }, + WantOutput: fstfmt.EncodeJSON(&fastly.KVStore{ + StoreID: storeID, + Name: storeName, + CreatedAt: &now, + UpdatedAt: &now, + }), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, "create"}, scenarios) +} + +func TestDeleteStoreCommand(t *testing.T) { + const storeID = "test123" + errStoreNotFound := errors.New("store not found") + + scenarios := []testutil.CLIScenario{ + { + WantError: "error parsing arguments: required flag --store-id not provided", + }, + { + Args: "--store-id DOES-NOT-EXIST", + API: mock.API{ + DeleteKVStoreFn: func(i *fastly.DeleteKVStoreInput) error { + if i.StoreID != storeID { + return errStoreNotFound + } + return nil + }, + }, + WantError: errStoreNotFound.Error(), + }, + { + Args: fmt.Sprintf("--store-id %s", storeID), + API: mock.API{ + DeleteKVStoreFn: func(i *fastly.DeleteKVStoreInput) error { + if i.StoreID != storeID { + return errStoreNotFound + } + return nil + }, + }, + WantOutput: fstfmt.Success("Deleted KV Store '%s'\n", storeID), + }, + { + Args: fmt.Sprintf("--store-id %s --json", storeID), + API: mock.API{ + DeleteKVStoreFn: func(i *fastly.DeleteKVStoreInput) error { + if i.StoreID != storeID { + return errStoreNotFound + } + return nil + }, + }, + WantOutput: fstfmt.JSON(`{"id": %q, "deleted": true}`, storeID), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, "delete"}, scenarios) +} + +func TestGetStoreCommand(t *testing.T) { + const ( + storeName = "test123" + storeID = "store-id-123" + ) + + now := time.Now() + + scenarios := []testutil.CLIScenario{ + { + WantError: "error parsing arguments: required flag --store-id not provided", + }, + { + Args: fmt.Sprintf("--store-id %s", storeID), + API: mock.API{ + GetKVStoreFn: func(_ *fastly.GetKVStoreInput) (*fastly.KVStore, error) { + return nil, errors.New("invalid request") + }, + }, + WantError: "invalid request", + }, + { + Args: fmt.Sprintf("--store-id %s", storeID), + API: mock.API{ + GetKVStoreFn: func(i *fastly.GetKVStoreInput) (*fastly.KVStore, error) { + return &fastly.KVStore{ + StoreID: i.StoreID, + Name: storeName, + CreatedAt: &now, + UpdatedAt: &now, + }, nil + }, + }, + WantOutput: fmtStore( + &fastly.KVStore{ + StoreID: storeID, + Name: storeName, + CreatedAt: &now, + UpdatedAt: &now, + }, + ), + }, + { + Args: fmt.Sprintf("--store-id %s --json", storeID), + API: mock.API{ + GetKVStoreFn: func(i *fastly.GetKVStoreInput) (*fastly.KVStore, error) { + return &fastly.KVStore{ + StoreID: i.StoreID, + Name: storeName, + CreatedAt: &now, + }, nil + }, + }, + WantOutput: fstfmt.EncodeJSON(&fastly.KVStore{ + StoreID: storeID, + Name: storeName, + CreatedAt: &now, + }), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, "get"}, scenarios) +} + +func TestListStoresCommand(t *testing.T) { + const ( + storeName = "test123" + storeID = "store-id-123" + ) + + now := time.Now() + + stores := &fastly.ListKVStoresResponse{ + Data: []fastly.KVStore{ + {StoreID: storeID, Name: storeName, CreatedAt: &now, UpdatedAt: &now}, + {StoreID: storeID + "+1", Name: storeName + "+1", CreatedAt: &now, UpdatedAt: &now}, + }, + } + + scenarios := []testutil.CLIScenario{ + { + API: mock.API{ + ListKVStoresFn: func(_ *fastly.ListKVStoresInput) (*fastly.ListKVStoresResponse, error) { + return nil, nil + }, + }, + }, + { + API: mock.API{ + ListKVStoresFn: func(_ *fastly.ListKVStoresInput) (*fastly.ListKVStoresResponse, error) { + return nil, errors.New("unknown error") + }, + }, + WantError: "unknown error", + }, + { + API: mock.API{ + ListKVStoresFn: func(_ *fastly.ListKVStoresInput) (*fastly.ListKVStoresResponse, error) { + return stores, nil + }, + }, + WantOutput: fmtStores(stores), + }, + { + Args: "--json", + API: mock.API{ + ListKVStoresFn: func(_ *fastly.ListKVStoresInput) (*fastly.ListKVStoresResponse, error) { + return stores, nil + }, + }, + WantOutput: fstfmt.EncodeJSON(stores), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, "list"}, scenarios) +} + +func fmtStore(ks *fastly.KVStore) string { + var b bytes.Buffer + text.PrintKVStore(&b, "", ks) + return b.String() +} + +func fmtStores(ks *fastly.ListKVStoresResponse) string { + var b bytes.Buffer + for _, o := range ks.Data { + // avoid gosec loop aliasing check :/ + o := o + text.PrintKVStore(&b, "", &o) + } + return b.String() +} diff --git a/pkg/commands/kvstore/list.go b/pkg/commands/kvstore/list.go new file mode 100644 index 000000000..8bcab3521 --- /dev/null +++ b/pkg/commands/kvstore/list.go @@ -0,0 +1,85 @@ +package kvstore + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// ListCommand calls the Fastly API to list the available kv stores. +type ListCommand struct { + argparser.Base + argparser.JSONOutput +} + +// NewListCommand returns a usable command registered under the parent. +func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { + c := ListCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("list", "List KV Stores") + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + + return &c +} + +// Exec invokes the application logic for the command. +func (c *ListCommand) Exec(in io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + var cursor string + + for { + o, err := c.Globals.APIClient.ListKVStores(&fastly.ListKVStoresInput{ + Cursor: cursor, + }) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + // No pagination prompt w/ JSON output. + // FIXME: This should be fixed here and for Secrets Store. + return err + } + + if o != nil { + for _, o := range o.Data { + // avoid gosec loop aliasing check :/ + o := o + text.PrintKVStore(out, "", &o) + } + if cur, ok := o.Meta["next_cursor"]; ok && cur != "" && cur != cursor { + if c.Globals.Flags.NonInteractive || c.Globals.Flags.AutoYes || !text.IsTTY(out) { + // If non-interactive or auto-yes, then load all data. + cursor = cur + continue + } + text.Break(out) + printNext, err := text.AskYesNo(out, "Print next page [y/N]: ", in) + if err != nil { + return err + } + if printNext { + text.Break(out) + cursor = cur + continue + } + } + } + + return nil + } +} diff --git a/pkg/commands/kvstore/root.go b/pkg/commands/kvstore/root.go new file mode 100644 index 000000000..b05d3b89a --- /dev/null +++ b/pkg/commands/kvstore/root.go @@ -0,0 +1,31 @@ +package kvstore + +import ( + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" +) + +// RootCommand is the parent command for all subcommands in this package. +// It should be installed under the primary root command. +type RootCommand struct { + argparser.Base + // no flags +} + +// CommandName is the string to be used to invoke this command. +const CommandName = "kv-store" + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent argparser.Registerer, g *global.Data) *RootCommand { + var c RootCommand + c.Globals = g + c.CmdClause = parent.Command(CommandName, "Manipulate Fastly KV Stores").Alias("object-store") + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { + panic("unreachable") +} diff --git a/pkg/commands/kvstoreentry/create.go b/pkg/commands/kvstoreentry/create.go new file mode 100644 index 000000000..ee5f932fd --- /dev/null +++ b/pkg/commands/kvstoreentry/create.go @@ -0,0 +1,420 @@ +package kvstoreentry + +import ( + "errors" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/api" + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/runtime" + "github.com/fastly/cli/pkg/text" +) + +// NewCreateCommand returns a usable command registered under the parent. +func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { + c := CreateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("create", "Insert a key-value pair").Alias("insert") + + // Required. + c.CmdClause.Flag("store-id", "Store ID").Short('s').Required().StringVar(&c.Input.StoreID) + + // Optional. + c.CmdClause.Flag("dir", "Path to a directory containing individual files where the filename is the key and the file contents is the value").StringVar(&c.dirPath) + c.CmdClause.Flag("dir-allow-hidden", "Allow hidden files (e.g. dot files) to be included (skipped by default)").BoolVar(&c.dirAllowHidden) + c.CmdClause.Flag("dir-concurrency", "Limit the number of concurrent network resources allocated").Default("50").IntVar(&c.dirConcurrency) + c.CmdClause.Flag("file", `Path to a file containing individual JSON objects (e.g., {"key":"...","value":"base64_encoded_value"}) separated by new-line delimiter`).StringVar(&c.filePath) + c.RegisterFlagBool(c.JSONFlag()) // --json + c.CmdClause.Flag("key", "Key name").Short('k').StringVar(&c.Input.Key) + c.CmdClause.Flag("stdin", "Read new-line separated JSON stream via STDIN").BoolVar(&c.stdin) + c.CmdClause.Flag("value", "Value").StringVar(&c.Input.Value) + + return &c +} + +// CreateCommand calls the Fastly API to insert a key into an kv store. +type CreateCommand struct { + argparser.Base + argparser.JSONOutput + + dirAllowHidden bool + dirConcurrency int + dirPath string + filePath string + stdin bool + + Input fastly.InsertKVStoreKeyInput +} + +// Exec invokes the application logic for the command. +func (c *CreateCommand) Exec(in io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + if err := c.CheckFlags(); err != nil { + return err + } + + if c.stdin { + return c.ProcessStdin(in, out) + } + + if c.filePath != "" { + return c.ProcessFile(out) + } + + if c.dirPath != "" { + return c.ProcessDir(in, out) + } + + if c.Input.Key == "" || c.Input.Value == "" { + return fsterr.ErrInvalidKVCombo + } + + err := c.Globals.APIClient.InsertKVStoreKey(&c.Input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if c.JSONOutput.Enabled { + o := struct { + ID string `json:"id"` + Key string `json:"key"` + }{ + c.Input.StoreID, + c.Input.Key, + } + _, err := c.WriteJSON(out, o) + return err + } + + text.Success(out, "Created key '%s' in KV Store '%s'", c.Input.Key, c.Input.StoreID) + return nil +} + +// CheckFlags ensures only one of the three specified flags are provided. +func (c *CreateCommand) CheckFlags() error { + flagCount := 0 + if c.stdin { + flagCount++ + } + if c.filePath != "" { + flagCount++ + } + if c.dirPath != "" { + flagCount++ + } + if flagCount > 1 { + return fsterr.ErrInvalidStdinFileDirCombo + } + return nil +} + +// ProcessStdin streams STDIN to the batch API endpoint. +func (c *CreateCommand) ProcessStdin(in io.Reader, out io.Writer) error { + // Determine if 'in' has data available. + if in == nil || text.IsTTY(in) { + return fsterr.ErrNoSTDINData + } + if c.Globals.Verbose() { + in = io.TeeReader(in, out) + } + return c.CallBatchEndpoint(in, out) +} + +// ProcessFile streams a JSON file content to the batch API endpoint. +func (c *CreateCommand) ProcessFile(out io.Writer) error { + f, err := os.Open(c.filePath) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + defer func() { + _ = f.Close() + }() + + var in io.Reader = f + if c.Globals.Verbose() { + in = io.TeeReader(f, out) + } + return c.CallBatchEndpoint(in, out) +} + +// ProcessDir concurrently reads files from the given directory structure and +// uploads each file to the set-value-for-key endpoint where the filename is the +// key and the file content is the value. +// +// NOTE: Unlike ProcessStdin/ProcessFile content doesn't need to be base64. +func (c *CreateCommand) ProcessDir(in io.Reader, out io.Writer) error { + if runtime.Windows { + cont, err := c.PromptWindowsUser(in, out) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + if !cont { + return nil + } + text.Break(out) + } + + path, err := filepath.Abs(c.dirPath) + if err != nil { + return err + } + + allFiles, err := os.ReadDir(path) + if err != nil { + return err + } + + filteredFiles := make([]fs.DirEntry, 0) + for _, file := range allFiles { + // Skip directories/symlinks OR any hidden files unless the user allows them. + if !file.Type().IsRegular() || (file.Type().IsRegular() && isHiddenFile(file.Name()) && !c.dirAllowHidden) { + continue + } + filteredFiles = append(filteredFiles, file) + } + + spinner, err := text.NewSpinner(out) + if err != nil { + return err + } + + err = spinner.Start() + if err != nil { + return err + } + filesTotal := len(filteredFiles) + msg := "%s %d of %d files" + spinner.Message(fmt.Sprintf(msg, "Processing", 0, filesTotal) + "...") + + processed := make(chan struct{}, c.dirConcurrency) + sem := make(chan struct{}, c.dirConcurrency) + filesVerboseOutput := make(chan string, filesTotal) + + var ( + processingErrors []ProcessErr + filesProcessed uint64 + // NOTE: mu protects access to the 'processingErrors' shared resource. + // We create multiple goroutines (one for each file) and each one has the + // potential to mutate the slice by appending new errors to it. + mu sync.Mutex + wg sync.WaitGroup + ) + + go func() { + for range processed { + atomic.AddUint64(&filesProcessed, 1) + spinner.Message(fmt.Sprintf(msg, "Processing", filesProcessed, filesTotal) + "...") + } + }() + + for _, file := range filteredFiles { + wg.Add(1) + + go func(file fs.DirEntry) { + // Restrict resource allocation if concurrency limit is exceeded. + sem <- struct{}{} + defer func() { + processed <- struct{}{} + <-sem + }() + defer wg.Done() + + filename := file.Name() + filePath := filepath.Join(path, filename) + + if c.Globals.Verbose() { + filesVerboseOutput <- filename + } + + // G304 (CWE-22): Potential file inclusion via variable + // #nosec + f, err := os.Open(filePath) + if err != nil { + mu.Lock() + processingErrors = append(processingErrors, ProcessErr{ + File: filePath, + Err: err, + }) + mu.Unlock() + return + } + + lr, err := fastly.FileLengthReader(f) + if err != nil { + mu.Lock() + processingErrors = append(processingErrors, ProcessErr{ + File: filePath, + Err: err, + }) + mu.Unlock() + return + } + + opts := insertKeyOptions{ + client: c.Globals.APIClient, + id: c.Input.StoreID, + key: filename, + file: lr, + } + + err = insertKey(opts) + if err != nil { + // In case the network connection is lost due to exhaustion of + // resources, then try one more time to make the request. + // + // NOTE: you can't type assert the error as it's not exported. + // https://github.com/golang/go/issues/54173 + if strings.Contains(err.Error(), "net/http: cannot rewind body after connection loss") { + err = insertKey(opts) + if err == nil { + return + } + } + mu.Lock() + processingErrors = append(processingErrors, ProcessErr{ + File: filePath, + Err: err, + }) + mu.Unlock() + return + } + }(file) + } + + wg.Wait() + + spinner.StopMessage(fmt.Sprintf(msg, "Processed", atomic.LoadUint64(&filesProcessed)-uint64(len(processingErrors)), filesTotal)) + err = spinner.Stop() + if err != nil { + return err + } + + if c.Globals.Verbose() { + close(filesVerboseOutput) + text.Break(out) + for filename := range filesVerboseOutput { + fmt.Println(filename) + } + } + + if len(processingErrors) == 0 { + text.Success(out, "\nInserted %d keys into KV Store", len(filteredFiles)) + return nil + } + + text.Break(out) + for _, err := range processingErrors { + fmt.Printf("File: %s\nError: %s\n\n", err.File, err.Err.Error()) + } + + return errors.New("failed to process all the provided files (see error log above ⬆️)") +} + +// PromptWindowsUser ensures a user understands that we only filter files whose +// name is prefixed with a dot and not any other kind of 'hidden' attribute that +// can be set by the Windows platform. +func (c *CreateCommand) PromptWindowsUser(in io.Reader, out io.Writer) (bool, error) { + if !c.Globals.Flags.AutoYes && !c.Globals.Flags.NonInteractive { + label := `The Fastly CLI will skip dotfiles (filenames prefixed with a period character, example: '.ignore') but this does not include files set with a "hidden" attribute). Are you sure you want to continue? [y/N] ` + result, err := text.AskYesNo(out, label, in) + if err != nil { + return false, err + } + return result, nil + } + return true, nil +} + +// CallBatchEndpoint calls the batch API endpoint. +func (c *CreateCommand) CallBatchEndpoint(in io.Reader, out io.Writer) error { + type result struct { + Success bool `json:"success"` + Errors []*fastly.ErrorObject `json:"errors,omitempty"` + } + + if err := c.Globals.APIClient.BatchModifyKVStoreKey(&fastly.BatchModifyKVStoreKeyInput{ + StoreID: c.Input.StoreID, + Body: in, + }); err != nil { + c.Globals.ErrLog.Add(err) + + r := result{Success: false} + + he, ok := err.(*fastly.HTTPError) + if ok { + r.Errors = append(r.Errors, he.Errors...) + } + + if c.JSONOutput.Enabled { + _, err := c.WriteJSON(out, r) + return err + } + + // If we were able to convert the error into a fastly.HTTPError, then + // display those errors to the user, otherwise we'll display the original + // error type. + if ok { + for i, e := range he.Errors { + text.Output(out, "Error %d", i) + text.Output(out, "Title: %s", e.Title) + text.Output(out, "Code: %s", e.Code) + text.Output(out, "Detail: %s", e.Detail) + text.Break(out) + } + return he + } + return err + } + + if c.JSONOutput.Enabled { + _, err := c.WriteJSON(out, result{Success: true}) + return err + } + + if c.Globals.Verbose() { + text.Break(out) + } + text.Success(out, "Inserted keys into KV Store") + return nil +} + +func insertKey(opts insertKeyOptions) error { + return opts.client.InsertKVStoreKey(&fastly.InsertKVStoreKeyInput{ + Body: opts.file, + StoreID: opts.id, + Key: opts.key, + }) +} + +type insertKeyOptions struct { + client api.Interface + id string + key string + file fastly.LengthReader +} + +// ProcessErr represents an error related to processing individual files. +type ProcessErr struct { + File string + Err error +} diff --git a/pkg/commands/kvstoreentry/delete.go b/pkg/commands/kvstoreentry/delete.go new file mode 100644 index 000000000..a0c59f461 --- /dev/null +++ b/pkg/commands/kvstoreentry/delete.go @@ -0,0 +1,213 @@ +package kvstoreentry + +import ( + "fmt" + "io" + "strconv" + "sync" + "sync/atomic" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// DeleteKeysPoolSize is the goroutine/thread-pool size. +// Each pool will take a 'key' from a channel and issue a DELETE request. +const DeleteKeysPoolSize int = 100 + +// DeleteKeysMaxErrors is the maximum number of errors we'll allow before +// stopping the goroutines from executing. +const DeleteKeysMaxErrors int = 100 + +// DeleteCommand calls the Fastly API to delete an kv store. +type DeleteCommand struct { + argparser.Base + argparser.JSONOutput + key argparser.OptionalString + + // NOTE: Public fields can be set via `kv-store delete`. + DeleteAll bool + MaxErrors int + PoolSize int + StoreID string +} + +// NewDeleteCommand returns a usable command registered under the parent. +func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { + c := DeleteCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("delete", "Delete a key") + + // Required. + c.CmdClause.Flag("store-id", "Store ID").Short('s').Required().StringVar(&c.StoreID) + + // Optional. + c.CmdClause.Flag("all", "Delete all entries within the store").Short('a').BoolVar(&c.DeleteAll) + c.CmdClause.Flag("concurrency", "The thread pool size (ignored when set without the --all flag)").Default(strconv.Itoa(DeleteKeysPoolSize)).Short('r').IntVar(&c.PoolSize) + c.RegisterFlagBool(c.JSONFlag()) // --json + c.CmdClause.Flag("key", "Key name").Short('k').Action(c.key.Set).StringVar(&c.key.Value) + c.CmdClause.Flag("max-errors", "The number of errors to accept before stopping (ignored when set without the --all flag)").Default(strconv.Itoa(DeleteKeysMaxErrors)).Short('m').IntVar(&c.MaxErrors) + + return &c +} + +// Exec invokes the application logic for the command. +func (c *DeleteCommand) Exec(in io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + // TODO: Support --json for bulk deletions. + if c.DeleteAll && c.JSONOutput.Enabled { + return fsterr.ErrInvalidDeleteAllJSONKeyCombo + } + if c.DeleteAll && c.key.WasSet { + return fsterr.ErrInvalidDeleteAllKeyCombo + } + if !c.DeleteAll && !c.key.WasSet { + return fsterr.ErrMissingDeleteAllKeyCombo + } + + if c.DeleteAll { + if !c.Globals.Flags.AutoYes && !c.Globals.Flags.NonInteractive { + text.Warning(out, "This will delete ALL entries from your store!\n\n") + cont, err := text.AskYesNo(out, "Are you sure you want to continue? [y/N]: ", in) + if err != nil { + return err + } + if !cont { + return nil + } + text.Break(out) + } + return c.DeleteAllKeys(out) + } + + input := fastly.DeleteKVStoreKeyInput{ + StoreID: c.StoreID, + Key: c.key.Value, + } + + err := c.Globals.APIClient.DeleteKVStoreKey(&input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if c.JSONOutput.Enabled { + o := struct { + Key string `json:"key"` + ID string `json:"store_id"` + Deleted bool `json:"deleted"` + }{ + c.key.Value, + c.StoreID, + true, + } + _, err := c.WriteJSON(out, o) + return err + } + + text.Success(out, "Deleted key '%s' from KV Store '%s'", c.key.Value, c.StoreID) + return nil +} + +// DeleteAllKeys deletes all keys within the specified KV Store. +// NOTE: It's a public method as it can be called via `kv-store delete --all`. +func (c *DeleteCommand) DeleteAllKeys(out io.Writer) error { + spinnerMessage := "Deleting keys" + var spinner text.Spinner + + var err error + spinner, err = text.NewSpinner(out) + if err != nil { + return err + } + err = spinner.Start() + if err != nil { + return err + } + spinner.Message(spinnerMessage + "...") + + p := c.Globals.APIClient.NewListKVStoreKeysPaginator(&fastly.ListKVStoreKeysInput{ + StoreID: c.StoreID, + }) + + errorsCh := make(chan string, c.MaxErrors) + keysCh := make(chan string, 1000) // number correlates to pagination page size + + var ( + deleteCount atomic.Uint64 + failedKeys []string + wg sync.WaitGroup + ) + + // We have two separate execution flows happening at once: + // + // 1. Pushing keys from pagination data into a key channel. + // 2. Pulling keys from key channel and issuing API DELETE call. + // + // We have a limit on the number of errors. Once that limit is reached we'll + // stop the 2. set of goroutines. + + wg.Add(1) + go func() { + defer wg.Done() + defer close(keysCh) + for p.Next() { + for _, key := range p.Keys() { + keysCh <- key + } + } + }() + + for range c.PoolSize { + wg.Add(1) + go func() { + defer wg.Done() + for key := range keysCh { + err := c.Globals.APIClient.DeleteKVStoreKey(&fastly.DeleteKVStoreKeyInput{StoreID: c.StoreID, Key: key}) + if err != nil { + select { + case errorsCh <- key: + default: + return // channel is blocked + } + } + spinner.Message(spinnerMessage + "..." + strconv.FormatUint(deleteCount.Add(1), 10)) + } + }() + } + + wg.Wait() + + close(errorsCh) + for err := range errorsCh { + failedKeys = append(failedKeys, err) + } + + spinnerMessage = "Deleted keys: " + strconv.FormatUint(deleteCount.Load(), 10) + + if len(failedKeys) > 0 { + spinner.StopFailMessage(spinnerMessage) + err := spinner.StopFail() + if err != nil { + return fmt.Errorf("failed to stop spinner: %w", err) + } + return fmt.Errorf("failed to delete %d keys", len(failedKeys)) + } + + spinner.StopMessage(spinnerMessage) + if err := spinner.Stop(); err != nil { + return fmt.Errorf("failed to stop spinner: %w", err) + } + + text.Success(out, "\nDeleted all keys from KV Store '%s'", c.StoreID) + return nil +} diff --git a/pkg/commands/kvstoreentry/describe.go b/pkg/commands/kvstoreentry/describe.go new file mode 100644 index 000000000..01d4faaff --- /dev/null +++ b/pkg/commands/kvstoreentry/describe.go @@ -0,0 +1,67 @@ +package kvstoreentry + +import ( + "fmt" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// DescribeCommand calls the Fastly API to fetch the value of a key from an kv store. +type DescribeCommand struct { + argparser.Base + argparser.JSONOutput + + Input fastly.GetKVStoreKeyInput +} + +// NewDescribeCommand returns a usable command registered under the parent. +func NewDescribeCommand(parent argparser.Registerer, g *global.Data) *DescribeCommand { + c := DescribeCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("describe", "Get the value associated with a key").Alias("get") + + // Required. + c.CmdClause.Flag("key", "Key name").Short('k').Required().StringVar(&c.Input.Key) + c.CmdClause.Flag("store-id", "Store ID").Short('s').Required().StringVar(&c.Input.StoreID) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + + return &c +} + +// Exec invokes the application logic for the command. +func (c *DescribeCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + value, err := c.Globals.APIClient.GetKVStoreKey(&c.Input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if c.JSONOutput.Enabled { + text.Output(out, `{"%s": "%s"}`, c.Input.Key, value) + return nil + } + + if c.Globals.Flags.Verbose { + text.PrintKVStoreKeyValue(out, "", c.Input.Key, value) + return nil + } + + // IMPORTANT: Don't use `text` package as binary data can be messed up. + fmt.Fprint(out, value) + return nil +} diff --git a/pkg/commands/kvstoreentry/doc.go b/pkg/commands/kvstoreentry/doc.go new file mode 100644 index 000000000..9c0de9deb --- /dev/null +++ b/pkg/commands/kvstoreentry/doc.go @@ -0,0 +1,3 @@ +// Package kvstoreentry contains commands to inspect and manipulate Fastly edge +// kv stores keys. +package kvstoreentry diff --git a/pkg/commands/kvstoreentry/hidden.go b/pkg/commands/kvstoreentry/hidden.go new file mode 100644 index 000000000..95f8fad32 --- /dev/null +++ b/pkg/commands/kvstoreentry/hidden.go @@ -0,0 +1,5 @@ +package kvstoreentry + +func isHiddenFile(filename string) bool { + return filename[0] == '.' +} diff --git a/pkg/commands/kvstoreentry/kvstoreentry_test.go b/pkg/commands/kvstoreentry/kvstoreentry_test.go new file mode 100644 index 000000000..364a959d5 --- /dev/null +++ b/pkg/commands/kvstoreentry/kvstoreentry_test.go @@ -0,0 +1,300 @@ +package kvstoreentry_test + +import ( + "errors" + "fmt" + "path/filepath" + "strings" + "testing" + + "github.com/fastly/go-fastly/v10/fastly" + + root "github.com/fastly/cli/pkg/commands/kvstoreentry" + fstfmt "github.com/fastly/cli/pkg/fmt" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" +) + +func TestCreateCommand(t *testing.T) { + const ( + storeID = "store-id-123" + itemKey = "foo" + itemValue = "the-value" + ) + + scenarios := []testutil.CLIScenario{ + { + Args: "--key a-key --value a-value", + WantError: "error parsing arguments: required flag --store-id not provided", + }, + { + Args: fmt.Sprintf("--store-id %s --key %s --value %s", storeID, itemKey, itemValue), + API: mock.API{ + InsertKVStoreKeyFn: func(_ *fastly.InsertKVStoreKeyInput) error { + return errors.New("invalid request") + }, + }, + WantError: "invalid request", + }, + { + Args: fmt.Sprintf("--store-id %s --key %s --value %s", storeID, itemKey, itemValue), + API: mock.API{ + InsertKVStoreKeyFn: func(_ *fastly.InsertKVStoreKeyInput) error { + return nil + }, + }, + WantOutput: fstfmt.Success("Created key '%s' in KV Store '%s'", itemKey, storeID), + }, + { + Args: fmt.Sprintf("--store-id %s --key %s --value %s --json", storeID, itemKey, itemValue), + API: mock.API{ + InsertKVStoreKeyFn: func(_ *fastly.InsertKVStoreKeyInput) error { + return nil + }, + }, + WantOutput: fstfmt.JSON(`{"id": %q, "key": %q}`, storeID, itemKey), + }, + { + Args: fmt.Sprintf("--store-id %s --stdin", storeID), + Stdin: []string{`{"key":"example","value":"VkFMVUU="}`}, + API: mock.API{ + BatchModifyKVStoreKeyFn: func(_ *fastly.BatchModifyKVStoreKeyInput) error { + return nil + }, + }, + WantOutput: "SUCCESS: Inserted keys into KV Store\n", + }, + { + Args: fmt.Sprintf("--store-id %s --file %s", storeID, filepath.Join("testdata", "data.json")), + API: mock.API{ + BatchModifyKVStoreKeyFn: func(_ *fastly.BatchModifyKVStoreKeyInput) error { + return nil + }, + }, + WantOutput: "SUCCESS: Inserted keys into KV Store\n", + }, + { + Args: fmt.Sprintf("--store-id %s --dir %s", storeID, filepath.Join("testdata", "example")), + Stdin: []string{"y"}, + API: mock.API{ + InsertKVStoreKeyFn: func(i *fastly.InsertKVStoreKeyInput) error { + if i.Key == "foo.txt" { + return nil + } + return errors.New("invalid request") + }, + }, + WantOutput: "SUCCESS: Inserted 1 keys into KV Store", + }, + { + Args: fmt.Sprintf("--store-id %s --dir %s --dir-allow-hidden", storeID, filepath.Join("testdata", "example")), + Stdin: []string{"y"}, + API: mock.API{ + InsertKVStoreKeyFn: func(i *fastly.InsertKVStoreKeyInput) error { + if i.Key == "foo.txt" || i.Key == ".hiddenfile" { + return nil + } + return errors.New("invalid request") + }, + }, + WantOutput: "SUCCESS: Inserted 2 keys into KV Store", + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, "create"}, scenarios) +} + +func TestDeleteCommand(t *testing.T) { + const ( + storeID = "store-id-123" + itemKey = "foo" + ) + + scenarios := []testutil.CLIScenario{ + { + Args: "--key a-key", + WantError: "error parsing arguments: required flag --store-id not provided", + }, + { + Args: "--store-id " + storeID, + WantError: "invalid command, neither --all or --key provided", + }, + { + Args: "--json --all --store-id " + storeID, + WantError: "invalid flag combination, --all and --json", + }, + { + Args: "--key a-key --all --store-id " + storeID, + WantError: "invalid flag combination, --all and --key", + }, + { + Args: fmt.Sprintf("--store-id %s --key %s", storeID, itemKey), + API: mock.API{ + DeleteKVStoreKeyFn: func(_ *fastly.DeleteKVStoreKeyInput) error { + return errors.New("invalid request") + }, + }, + WantError: "invalid request", + }, + { + Args: fmt.Sprintf("--store-id %s --key %s", storeID, itemKey), + API: mock.API{ + DeleteKVStoreKeyFn: func(_ *fastly.DeleteKVStoreKeyInput) error { + return nil + }, + }, + WantOutput: fstfmt.Success("Deleted key '%s' from KV Store '%s'", itemKey, storeID), + }, + { + Args: fmt.Sprintf("--store-id %s --key %s --json", storeID, itemKey), + API: mock.API{ + DeleteKVStoreKeyFn: func(_ *fastly.DeleteKVStoreKeyInput) error { + return nil + }, + }, + WantOutput: fstfmt.JSON(`{"key": "%s", "store_id": "%s", "deleted": true}`, itemKey, storeID), + }, + { + Args: fmt.Sprintf("--store-id %s --all --auto-yes", storeID), + API: mock.API{ + NewListKVStoreKeysPaginatorFn: func(_ *fastly.ListKVStoreKeysInput) fastly.PaginatorKVStoreEntries { + return &mockKVStoresEntriesPaginator{ + next: true, + keys: []string{"foo", "bar", "baz"}, + } + }, + DeleteKVStoreKeyFn: func(_ *fastly.DeleteKVStoreKeyInput) error { + return nil + }, + }, + WantOutput: "Deleting keys...", + }, + { + Args: fmt.Sprintf("--store-id %s --all --auto-yes", storeID), + API: mock.API{ + NewListKVStoreKeysPaginatorFn: func(_ *fastly.ListKVStoreKeysInput) fastly.PaginatorKVStoreEntries { + return &mockKVStoresEntriesPaginator{ + next: true, + keys: []string{"foo", "bar", "baz"}, + } + }, + DeleteKVStoreKeyFn: func(_ *fastly.DeleteKVStoreKeyInput) error { + return errors.New("whoops") + }, + }, + WantError: "failed to delete 3 keys", + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, "delete"}, scenarios) +} + +func TestGetCommand(t *testing.T) { + const ( + storeID = "store-id-123" + itemKey = "foo" + itemValue = "a value" + ) + + scenarios := []testutil.CLIScenario{ + { + Args: "--key a-key", + WantError: "error parsing arguments: required flag --store-id not provided", + }, + { + Args: fmt.Sprintf("--store-id %s --key %s", storeID, itemKey), + API: mock.API{ + GetKVStoreKeyFn: func(_ *fastly.GetKVStoreKeyInput) (string, error) { + return "", errors.New("invalid request") + }, + }, + WantError: "invalid request", + }, + { + Args: fmt.Sprintf("--store-id %s --key %s", storeID, itemKey), + API: mock.API{ + GetKVStoreKeyFn: func(_ *fastly.GetKVStoreKeyInput) (string, error) { + return itemValue, nil + }, + }, + WantOutput: itemValue, + }, + { + Args: fmt.Sprintf("--store-id %s --key %s --json", storeID, itemKey), + API: mock.API{ + GetKVStoreKeyFn: func(_ *fastly.GetKVStoreKeyInput) (string, error) { + return itemValue, nil + }, + }, + WantOutput: fmt.Sprintf(`{"%s": "%s"}`, itemKey, itemValue) + "\n", + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, "get"}, scenarios) +} + +func TestListCommand(t *testing.T) { + const storeID = "store-id-123" + + testItems := make([]string, 3) + for i := range testItems { + testItems[i] = fmt.Sprintf("key-%02d", i) + } + + scenarios := []testutil.CLIScenario{ + { + WantError: "error parsing arguments: required flag --store-id not provided", + }, + { + Args: fmt.Sprintf("--store-id %s", storeID), + API: mock.API{ + ListKVStoreKeysFn: func(_ *fastly.ListKVStoreKeysInput) (*fastly.ListKVStoreKeysResponse, error) { + return nil, errors.New("invalid request") + }, + }, + WantError: "invalid request", + }, + { + Args: fmt.Sprintf("--store-id %s", storeID), + API: mock.API{ + ListKVStoreKeysFn: func(_ *fastly.ListKVStoreKeysInput) (*fastly.ListKVStoreKeysResponse, error) { + return &fastly.ListKVStoreKeysResponse{Data: testItems}, nil + }, + }, + WantOutput: strings.Join(testItems, "\n") + "\n", + }, + { + Args: fmt.Sprintf("--store-id %s --json", storeID), + API: mock.API{ + ListKVStoreKeysFn: func(_ *fastly.ListKVStoreKeysInput) (*fastly.ListKVStoreKeysResponse, error) { + return &fastly.ListKVStoreKeysResponse{Data: testItems}, nil + }, + }, + WantOutput: fstfmt.EncodeJSON(testItems), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, "list"}, scenarios) +} + +type mockKVStoresEntriesPaginator struct { + next bool + keys []string + err error +} + +func (m *mockKVStoresEntriesPaginator) Next() bool { + ret := m.next + if m.next { + m.next = false // allow one instance of true before stopping + } + return ret +} + +func (m *mockKVStoresEntriesPaginator) Keys() []string { + return m.keys +} + +func (m *mockKVStoresEntriesPaginator) Err() error { + return m.err +} diff --git a/pkg/commands/kvstoreentry/list.go b/pkg/commands/kvstoreentry/list.go new file mode 100644 index 000000000..6430cfd80 --- /dev/null +++ b/pkg/commands/kvstoreentry/list.go @@ -0,0 +1,137 @@ +package kvstoreentry + +import ( + "fmt" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// ListCommand calls the Fastly API to list the keys for a given kv store. +type ListCommand struct { + argparser.Base + argparser.JSONOutput + + consistency string + Input fastly.ListKVStoreKeysInput +} + +// ConsistencyOptions is a list of allowed consistency values. +var ConsistencyOptions = []string{ + "eventual", + "strong", +} + +// NewListCommand returns a usable command registered under the parent. +func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { + c := ListCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + + c.CmdClause = parent.Command("list", "List keys") + + // Required. + c.CmdClause.Flag("store-id", "Store ID").Short('s').Required().StringVar(&c.Input.StoreID) + + // Optional. + c.CmdClause.Flag("consistency", "Determines accuracy of results. i.e. 'eventual' uses caching to improve performance").Default("strong").HintOptions(ConsistencyOptions...).EnumVar(&c.consistency, ConsistencyOptions...) + c.RegisterFlagBool(c.JSONFlag()) // --json + return &c +} + +// Exec invokes the application logic for the command. +func (c *ListCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + var ( + cursor string + keys []string + ok bool + ) + + c.Input.Cursor = cursor + + spinner, err := text.NewSpinner(out) + if err != nil { + return err + } + msg := "Getting data" + + // A spinner produces output and is incompatible with JSON expected output. + if !c.JSONOutput.Enabled { + err := spinner.Start() + if err != nil { + return err + } + spinner.Message(msg + "... (this can take a few minutes depending on the number of entries)") + } + + switch c.consistency { + case "eventual": + c.Input.Consistency = fastly.ConsistencyEventual + case "strong": + c.Input.Consistency = fastly.ConsistencyStrong + } + + for { + o, err := c.Globals.APIClient.ListKVStoreKeys(&c.Input) + if err != nil { + c.Globals.ErrLog.Add(err) + if !c.JSONOutput.Enabled { + spinner.StopFailMessage(msg) + spinErr := spinner.StopFail() + if spinErr != nil { + return fmt.Errorf(text.SpinnerErrWrapper, spinErr, err) + } + } + return err + } + + keys = append(keys, o.Data...) + + c.Input.Cursor, ok = o.Meta["next_cursor"] + if !ok { + break + } + } + + if !c.JSONOutput.Enabled { + spinner.StopMessage(msg) + err := spinner.Stop() + if err != nil { + return err + } + } + + if keys == nil { + if ok, err := c.WriteJSON(out, []string{}); ok { + return err + } + text.Break(out) + text.Output(out, "no keys") + return nil + } + + if ok, err := c.WriteJSON(out, keys); ok { + return err + } + + if c.Globals.Flags.Verbose { + text.PrintKVStoreKeys(out, "", keys) + return nil + } + + for _, k := range keys { + text.Output(out, k) + } + return nil +} diff --git a/pkg/commands/kvstoreentry/root.go b/pkg/commands/kvstoreentry/root.go new file mode 100644 index 000000000..1e26b8bd7 --- /dev/null +++ b/pkg/commands/kvstoreentry/root.go @@ -0,0 +1,31 @@ +package kvstoreentry + +import ( + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" +) + +// RootCommand is the parent command for all subcommands in this package. +// It should be installed under the primary root command. +type RootCommand struct { + argparser.Base + // no flags +} + +// CommandName is the string to be used to invoke this command. +const CommandName = "kv-store-entry" + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent argparser.Registerer, g *global.Data) *RootCommand { + var c RootCommand + c.Globals = g + c.CmdClause = parent.Command(CommandName, "Manipulate Fastly KV Store keys") + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { + panic("unreachable") +} diff --git a/pkg/commands/kvstoreentry/testdata/data.json b/pkg/commands/kvstoreentry/testdata/data.json new file mode 100644 index 000000000..94852993c --- /dev/null +++ b/pkg/commands/kvstoreentry/testdata/data.json @@ -0,0 +1,8 @@ +{ + "key": "file-example-1", + "value": "VkFMVUU=" +} +{ + "key": "file-example-2", + "value": "VkFMVUU=" +} diff --git a/pkg/commands/kvstoreentry/testdata/example/.hiddenfile b/pkg/commands/kvstoreentry/testdata/example/.hiddenfile new file mode 100644 index 000000000..284d6a346 --- /dev/null +++ b/pkg/commands/kvstoreentry/testdata/example/.hiddenfile @@ -0,0 +1 @@ +This file is hidden and should not be uploaded by default unless --dir-allow-hidden flag is set. diff --git a/pkg/commands/kvstoreentry/testdata/example/foo.txt b/pkg/commands/kvstoreentry/testdata/example/foo.txt new file mode 100644 index 000000000..b7d6715e2 --- /dev/null +++ b/pkg/commands/kvstoreentry/testdata/example/foo.txt @@ -0,0 +1 @@ +FOO diff --git a/pkg/commands/logging/azureblob/azureblob_integration_test.go b/pkg/commands/logging/azureblob/azureblob_integration_test.go new file mode 100644 index 000000000..6c845973f --- /dev/null +++ b/pkg/commands/logging/azureblob/azureblob_integration_test.go @@ -0,0 +1,521 @@ +package azureblob_test + +import ( + "bytes" + "errors" + "io" + "strings" + "testing" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/app" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" +) + +func TestBlobStorageCreate(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging azureblob create --service-id 123 --version 1 --name log --account-name account --container log --sas-token abc --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + CreateBlobStorageFn: createBlobStorageOK, + }, + wantOutput: "Created Azure Blob Storage logging endpoint log (service 123 version 4)", + }, + { + args: args("logging azureblob create --service-id 123 --version 1 --name log --account-name account --container log --sas-token abc --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + CreateBlobStorageFn: createBlobStorageError, + }, + wantError: errTest.Error(), + }, + { + args: args("logging azureblob create --service-id 123 --version 1 --name log --account-name account --container log --sas-token abc --compression-codec zstd --gzip-level 9 --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + CreateBlobStorageFn: createBlobStorageError, + }, + wantError: "error parsing arguments: the --compression-codec flag is mutually exclusive with the --gzip-level flag", + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertStringContains(t, stdout.String(), testcase.wantOutput) + }) + } +} + +func TestBlobStorageList(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging azureblob list --service-id 123 --version 1"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListBlobStoragesFn: listBlobStoragesOK, + }, + wantOutput: listBlobStoragesShortOutput, + }, + { + args: args("logging azureblob list --service-id 123 --version 1 --verbose"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListBlobStoragesFn: listBlobStoragesOK, + }, + wantOutput: listBlobStoragesVerboseOutput, + }, + { + args: args("logging azureblob list --service-id 123 --version 1 -v"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListBlobStoragesFn: listBlobStoragesOK, + }, + wantOutput: listBlobStoragesVerboseOutput, + }, + { + args: args("logging azureblob --verbose list --service-id 123 --version 1"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListBlobStoragesFn: listBlobStoragesOK, + }, + wantOutput: listBlobStoragesVerboseOutput, + }, + { + args: args("logging -v azureblob list --service-id 123 --version 1"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListBlobStoragesFn: listBlobStoragesOK, + }, + wantOutput: listBlobStoragesVerboseOutput, + }, + { + args: args("logging azureblob list --service-id 123 --version 1"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListBlobStoragesFn: listBlobStoragesError, + }, + wantError: errTest.Error(), + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertString(t, testcase.wantOutput, stdout.String()) + }) + } +} + +func TestBlobStorageDescribe(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging azureblob describe --service-id 123 --version 1"), + wantError: "error parsing arguments: required flag --name not provided", + }, + { + args: args("logging azureblob describe --service-id 123 --version 1 --name logs"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + GetBlobStorageFn: getBlobStorageError, + }, + wantError: errTest.Error(), + }, + { + args: args("logging azureblob describe --service-id 123 --version 1 --name logs"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + GetBlobStorageFn: getBlobStorageOK, + }, + wantOutput: describeBlobStorageOutput, + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertString(t, testcase.wantOutput, stdout.String()) + }) + } +} + +func TestBlobStorageUpdate(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging azureblob update --service-id 123 --version 1 --new-name log"), + wantError: "error parsing arguments: required flag --name not provided", + }, + { + args: args("logging azureblob update --service-id 123 --version 1 --name logs --new-name log --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + UpdateBlobStorageFn: updateBlobStorageError, + }, + wantError: errTest.Error(), + }, + { + args: args("logging azureblob update --service-id 123 --version 1 --name logs --new-name log --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + UpdateBlobStorageFn: updateBlobStorageOK, + }, + wantOutput: "Updated Azure Blob Storage logging endpoint log (service 123 version 4)", + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertStringContains(t, stdout.String(), testcase.wantOutput) + }) + } +} + +func TestBlobStorageDelete(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging azureblob delete --service-id 123 --version 1"), + wantError: "error parsing arguments: required flag --name not provided", + }, + { + args: args("logging azureblob delete --service-id 123 --version 1 --name logs --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + DeleteBlobStorageFn: deleteBlobStorageError, + }, + wantError: errTest.Error(), + }, + { + args: args("logging azureblob delete --service-id 123 --version 1 --name logs --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + DeleteBlobStorageFn: deleteBlobStorageOK, + }, + wantOutput: "Deleted Azure Blob Storage logging endpoint logs (service 123 version 4)", + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertStringContains(t, stdout.String(), testcase.wantOutput) + }) + } +} + +var errTest = errors.New("fixture error") + +func createBlobStorageOK(i *fastly.CreateBlobStorageInput) (*fastly.BlobStorage, error) { + s := fastly.BlobStorage{ + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("log"), + Path: fastly.ToPointer("/logs"), + AccountName: fastly.ToPointer("account"), + Container: fastly.ToPointer("container"), + SASToken: fastly.ToPointer("token"), + Period: fastly.ToPointer(3600), + TimestampFormat: fastly.ToPointer("%Y-%m-%dT%H:%M:%S.000"), + PublicKey: fastly.ToPointer(pgpPublicKey()), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + FormatVersion: fastly.ToPointer(2), + MessageType: fastly.ToPointer("classic"), + Placement: fastly.ToPointer("none"), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + CompressionCodec: fastly.ToPointer("zstd"), + } + + return &s, nil +} + +func createBlobStorageError(_ *fastly.CreateBlobStorageInput) (*fastly.BlobStorage, error) { + return nil, errTest +} + +func listBlobStoragesOK(i *fastly.ListBlobStoragesInput) ([]*fastly.BlobStorage, error) { + return []*fastly.BlobStorage{ + { + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("logs"), + Path: fastly.ToPointer("/logs"), + AccountName: fastly.ToPointer("account"), + Container: fastly.ToPointer("container"), + SASToken: fastly.ToPointer("token"), + Period: fastly.ToPointer(3600), + TimestampFormat: fastly.ToPointer("%Y-%m-%dT%H:%M:%S.000"), + PublicKey: fastly.ToPointer(pgpPublicKey()), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + FormatVersion: fastly.ToPointer(2), + MessageType: fastly.ToPointer("classic"), + Placement: fastly.ToPointer("none"), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + CompressionCodec: fastly.ToPointer("zstd"), + }, + { + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("analytics"), + AccountName: fastly.ToPointer("account"), + Container: fastly.ToPointer("analytics"), + SASToken: fastly.ToPointer("token"), + Path: fastly.ToPointer("/logs"), + Period: fastly.ToPointer(86400), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + FormatVersion: fastly.ToPointer(2), + MessageType: fastly.ToPointer("classic"), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + TimestampFormat: fastly.ToPointer("%Y-%m-%dT%H:%M:%S.000"), + Placement: fastly.ToPointer("none"), + PublicKey: fastly.ToPointer(pgpPublicKey()), + CompressionCodec: fastly.ToPointer("zstd"), + }, + }, nil +} + +func listBlobStoragesError(_ *fastly.ListBlobStoragesInput) ([]*fastly.BlobStorage, error) { + return nil, errTest +} + +var listBlobStoragesShortOutput = strings.TrimSpace(` +SERVICE VERSION NAME +123 1 logs +123 1 analytics +`) + "\n" + +var listBlobStoragesVerboseOutput = strings.TrimSpace(` +Fastly API endpoint: https://api.fastly.com +Fastly API token provided via config file (profile: user) + +Service ID (via --service-id): 123 + +Version: 1 + BlobStorage 1/2 + Service ID: 123 + Version: 1 + Name: logs + Container: container + Account name: account + SAS token: token + Path: /logs + Period: 3600 + GZip level: 0 + Format: %h %l %u %t "%r" %>s %b + Format version: 2 + Response condition: Prevent default logging + Message type: classic + Timestamp format: %Y-%m-%dT%H:%M:%S.000 + Placement: none + Public key: `+pgpPublicKey()+` + File max bytes: 0 + Compression codec: zstd + BlobStorage 2/2 + Service ID: 123 + Version: 1 + Name: analytics + Container: analytics + Account name: account + SAS token: token + Path: /logs + Period: 86400 + GZip level: 0 + Format: %h %l %u %t "%r" %>s %b + Format version: 2 + Response condition: Prevent default logging + Message type: classic + Timestamp format: %Y-%m-%dT%H:%M:%S.000 + Placement: none + Public key: `+pgpPublicKey()+` + File max bytes: 0 + Compression codec: zstd +`) + "\n\n" + +func getBlobStorageOK(i *fastly.GetBlobStorageInput) (*fastly.BlobStorage, error) { + return &fastly.BlobStorage{ + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("logs"), + Container: fastly.ToPointer("container"), + AccountName: fastly.ToPointer("account"), + SASToken: fastly.ToPointer("token"), + Path: fastly.ToPointer("/logs"), + Period: fastly.ToPointer(3600), + GzipLevel: fastly.ToPointer(0), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + FormatVersion: fastly.ToPointer(2), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + MessageType: fastly.ToPointer("classic"), + TimestampFormat: fastly.ToPointer("%Y-%m-%dT%H:%M:%S.000"), + Placement: fastly.ToPointer("none"), + PublicKey: fastly.ToPointer(pgpPublicKey()), + CompressionCodec: fastly.ToPointer("zstd"), + }, nil +} + +func getBlobStorageError(_ *fastly.GetBlobStorageInput) (*fastly.BlobStorage, error) { + return nil, errTest +} + +var describeBlobStorageOutput = "\n" + strings.TrimSpace(` +Account name: account +Compression codec: zstd +Container: container +File max bytes: 0 +Format: %h %l %u %t "%r" %>s %b +Format version: 2 +GZip level: 0 +Message type: classic +Name: logs +Path: /logs +Period: 3600 +Placement: none +Public key: `+pgpPublicKey()+` +Response condition: Prevent default logging +SAS token: token +Service ID: 123 +Timestamp format: %Y-%m-%dT%H:%M:%S.000 +Version: 1 +`) + "\n" + +func updateBlobStorageOK(i *fastly.UpdateBlobStorageInput) (*fastly.BlobStorage, error) { + return &fastly.BlobStorage{ + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("log"), + Container: fastly.ToPointer("container"), + AccountName: fastly.ToPointer("account"), + SASToken: fastly.ToPointer("token"), + Path: fastly.ToPointer("/logs"), + Period: fastly.ToPointer(3600), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + FormatVersion: fastly.ToPointer(2), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + MessageType: fastly.ToPointer("classic"), + TimestampFormat: fastly.ToPointer("%Y-%m-%dT%H:%M:%S.000"), + Placement: fastly.ToPointer("none"), + PublicKey: fastly.ToPointer(pgpPublicKey()), + CompressionCodec: fastly.ToPointer("zstd"), + }, nil +} + +func updateBlobStorageError(_ *fastly.UpdateBlobStorageInput) (*fastly.BlobStorage, error) { + return nil, errTest +} + +func deleteBlobStorageOK(_ *fastly.DeleteBlobStorageInput) error { + return nil +} + +func deleteBlobStorageError(_ *fastly.DeleteBlobStorageInput) error { + return errTest +} + +// pgpPublicKey returns a PEM encoded PGP public key suitable for testing. +func pgpPublicKey() string { + return strings.TrimSpace(`-----BEGIN PGP PUBLIC KEY BLOCK----- +mQENBFyUD8sBCACyFnB39AuuTygseek+eA4fo0cgwva6/FSjnWq7riouQee8GgQ/ +ibXTRyv4iVlwI12GswvMTIy7zNvs1R54i0qvsLr+IZ4GVGJqs6ZJnvQcqe3xPoR4 +8AnBfw90o32r/LuHf6QCJXi+AEu35koNlNAvLJ2B+KACaNB7N0EeWmqpV/1V2k9p +lDYk+th7LcCuaFNGqKS/PrMnnMqR6VDLCjHhNx4KR79b0Twm/2qp6an3hyNRu8Gn +dwxpf1/BUu3JWf+LqkN4Y3mbOmSUL3MaJNvyQguUzTfS0P0uGuBDHrJCVkMZCzDB +89ag55jCPHyGeHBTd02gHMWzsg3WMBWvCsrzABEBAAG0JXRlcnJhZm9ybSAodGVz +dCkgPHRlc3RAdGVycmFmb3JtLmNvbT6JAU4EEwEIADgWIQSHYyc6Kj9l6HzQsau6 +vFFc9jxV/wUCXJQPywIbAwULCQgHAgYVCgkICwIEFgIDAQIeAQIXgAAKCRC6vFFc +9jxV/815CAClb32OxV7wG01yF97TzlyTl8TnvjMtoG29Mw4nSyg+mjM3b8N7iXm9 +OLX59fbDAWtBSldSZE22RXd3CvlFOG/EnKBXSjBtEqfyxYSnyOPkMPBYWGL/ApkX +SvPYJ4LKdvipYToKFh3y9kk2gk1DcDBDyaaHvR+3rv1u3aoy7/s2EltAfDS3ZQIq +7/cWTLJml/lleeB/Y6rPj8xqeCYhE5ahw9gsV/Mdqatl24V9Tks30iijx0Hhw+Gx +kATUikMGr2GDVqoIRga5kXI7CzYff4rkc0Twn47fMHHHe/KY9M2yVnMHUXmAZwbG +M1cMI/NH1DjevCKdGBLcRJlhuLPKF/anuQENBFyUD8sBCADIpd7r7GuPd6n/Ikxe +u6h7umV6IIPoAm88xCYpTbSZiaK30Svh6Ywra9jfE2KlU9o6Y/art8ip0VJ3m07L +4RSfSpnzqgSwdjSq5hNour2Fo/BzYhK7yaz2AzVSbe33R0+RYhb4b/6N+bKbjwGF +ftCsqVFMH+PyvYkLbvxyQrHlA9woAZaNThI1ztO5rGSnGUR8xt84eup28WIFKg0K +UEGUcTzz+8QGAwAra+0ewPXo/AkO+8BvZjDidP417u6gpBHOJ9qYIcO9FxHeqFyu +YrjlrxowEgXn5wO8xuNz6Vu1vhHGDHGDsRbZF8pv1d5O+0F1G7ttZ2GRRgVBZPwi +kiyRABEBAAGJATYEGAEIACAWIQSHYyc6Kj9l6HzQsau6vFFc9jxV/wUCXJQPywIb +DAAKCRC6vFFc9jxV/9YOCACe8qmOSnKQpQfW+PqYOqo3dt7JyweTs3FkD6NT8Zml +dYy/vkstbTjPpX6aTvUZjkb46BVi7AOneVHpD5GBqvRsZ9iVgDYHaehmLCdKiG5L +3Tp90NN+QY5WDbsGmsyk6+6ZMYejb4qYfweQeduOj27aavCJdLkCYMoRKfcFYI8c +FaNmEfKKy/r1PO20NXEG6t9t05K/frHy6ZG8bCNYdpagfFVot47r9JaQqWlTNtIR +5+zkkSq/eG9BEtRij3a6cTdQbktdBzx2KBeI0PYc1vlZR0LpuFKZqY9vlE6vTGLR +wMfrTEOvx0NxUM3rpaCgEmuWbB1G1Hu371oyr4srrr+N +=28dr +-----END PGP PUBLIC KEY BLOCK----- +`) +} diff --git a/pkg/commands/logging/azureblob/azureblob_test.go b/pkg/commands/logging/azureblob/azureblob_test.go new file mode 100644 index 000000000..f22bcbe18 --- /dev/null +++ b/pkg/commands/logging/azureblob/azureblob_test.go @@ -0,0 +1,376 @@ +package azureblob_test + +import ( + "bytes" + "testing" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/logging/azureblob" + "github.com/fastly/cli/pkg/config" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" +) + +func TestCreateBlobStorageInput(t *testing.T) { + for _, testcase := range []struct { + name string + cmd *azureblob.CreateCommand + want *fastly.CreateBlobStorageInput + wantError string + }{ + { + name: "required values set flag serviceID", + cmd: createCommandRequired(), + want: &fastly.CreateBlobStorageInput{ + ServiceID: "123", + ServiceVersion: 4, + Name: fastly.ToPointer("logs"), + AccountName: fastly.ToPointer("account"), + Container: fastly.ToPointer("container"), + SASToken: fastly.ToPointer("token"), + }, + }, + { + name: "all values set flag serviceID", + cmd: createCommandAll(), + want: &fastly.CreateBlobStorageInput{ + ServiceID: "123", + ServiceVersion: 4, + Name: fastly.ToPointer("logs"), + Container: fastly.ToPointer("container"), + AccountName: fastly.ToPointer("account"), + SASToken: fastly.ToPointer("token"), + Path: fastly.ToPointer("/log"), + Period: fastly.ToPointer(3600), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + MessageType: fastly.ToPointer("classic"), + FormatVersion: fastly.ToPointer(2), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + TimestampFormat: fastly.ToPointer("%Y-%m-%dT%H:%M:%S.000"), + Placement: fastly.ToPointer("none"), + PublicKey: fastly.ToPointer(pgpPublicKey()), + CompressionCodec: fastly.ToPointer("zstd"), + }, + }, + { + name: "error missing serviceID", + cmd: createCommandMissingServiceID(), + want: nil, + wantError: errors.ErrNoServiceID.Error(), + }, + } { + t.Run(testcase.name, func(t *testing.T) { + var bs []byte + out := bytes.NewBuffer(bs) + verboseMode := true + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + AutoCloneFlag: testcase.cmd.AutoClone, + APIClient: testcase.cmd.Globals.APIClient, + Manifest: testcase.cmd.Manifest, + Out: out, + ServiceVersionFlag: testcase.cmd.ServiceVersion, + VerboseMode: verboseMode, + }) + + switch { + case err != nil && testcase.wantError == "": + t.Fatalf("unexpected error getting service details: %v", err) + return + case err != nil && testcase.wantError != "": + testutil.AssertErrorContains(t, err, testcase.wantError) + return + case err == nil && testcase.wantError != "": + t.Fatalf("expected error, have nil (service details: %s, %d)", serviceID, serviceVersion.Number) + case err == nil && testcase.wantError == "": + have, err := testcase.cmd.ConstructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertEqual(t, testcase.want, have) + } + }) + } +} + +func TestUpdateBlobStorageInput(t *testing.T) { + scenarios := []struct { + name string + cmd *azureblob.UpdateCommand + api mock.API + want *fastly.UpdateBlobStorageInput + wantError string + }{ + { + name: "all values set flag serviceID", + cmd: updateCommandAll(), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + GetBlobStorageFn: getBlobStorageOK, + }, + want: &fastly.UpdateBlobStorageInput{ + ServiceID: "123", + ServiceVersion: 4, + Name: "logs", + NewName: fastly.ToPointer("new1"), + Container: fastly.ToPointer("new2"), + AccountName: fastly.ToPointer("new3"), + SASToken: fastly.ToPointer("new4"), + Path: fastly.ToPointer("new5"), + Period: fastly.ToPointer(3601), + GzipLevel: fastly.ToPointer(0), + Format: fastly.ToPointer("new6"), + FormatVersion: fastly.ToPointer(3), + ResponseCondition: fastly.ToPointer("new7"), + MessageType: fastly.ToPointer("new8"), + TimestampFormat: fastly.ToPointer("new9"), + Placement: fastly.ToPointer("new10"), + PublicKey: fastly.ToPointer("new11"), + CompressionCodec: fastly.ToPointer("new12"), + }, + }, + { + name: "no updates", + cmd: updateCommandNoUpdates(), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + GetBlobStorageFn: getBlobStorageOK, + }, + want: &fastly.UpdateBlobStorageInput{ + ServiceID: "123", + ServiceVersion: 4, + Name: "logs", + }, + }, + { + name: "error missing serviceID", + cmd: updateCommandMissingServiceID(), + want: nil, + wantError: errors.ErrNoServiceID.Error(), + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(testcase.name, func(t *testing.T) { + testcase.cmd.Globals.APIClient = testcase.api + + var bs []byte + out := bytes.NewBuffer(bs) + verboseMode := true + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + AutoCloneFlag: testcase.cmd.AutoClone, + APIClient: testcase.api, + Manifest: testcase.cmd.Manifest, + Out: out, + ServiceVersionFlag: testcase.cmd.ServiceVersion, + VerboseMode: verboseMode, + }) + + switch { + case err != nil && testcase.wantError == "": + t.Fatalf("unexpected error getting service details: %v", err) + return + case err != nil && testcase.wantError != "": + testutil.AssertErrorContains(t, err, testcase.wantError) + return + case err == nil && testcase.wantError != "": + t.Fatalf("expected error, have nil (service details: %s, %d)", serviceID, serviceVersion.Number) + case err == nil && testcase.wantError == "": + have, err := testcase.cmd.ConstructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertEqual(t, testcase.want, have) + } + }) + } +} + +func createCommandRequired() *azureblob.CreateCommand { + var b bytes.Buffer + + g := global.Data{ + Config: config.File{}, + Env: config.Environment{}, + Output: &b, + } + // TODO: make consistent (in all other logging files) with syslog_test which + // uses a testcase.api field to assign the mock API to the global client. + g.APIClient, _ = mock.APIClient(mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + })("token", "endpoint", false) + + return &azureblob.CreateCommand{ + Base: argparser.Base{ + Globals: &g, + }, + Manifest: manifest.Data{ + Flag: manifest.Flag{ + ServiceID: "123", + }, + }, + ServiceVersion: argparser.OptionalServiceVersion{ + OptionalString: argparser.OptionalString{Value: "1"}, + }, + AutoClone: argparser.OptionalAutoClone{ + OptionalBool: argparser.OptionalBool{ + Optional: argparser.Optional{ + WasSet: true, + }, + Value: true, + }, + }, + EndpointName: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "logs"}, + Container: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "container"}, + AccountName: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "account"}, + SASToken: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "token"}, + } +} + +func createCommandAll() *azureblob.CreateCommand { + var b bytes.Buffer + + g := global.Data{ + Config: config.File{}, + Env: config.Environment{}, + Output: &b, + } + g.APIClient, _ = mock.APIClient(mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + })("token", "endpoint", false) + + return &azureblob.CreateCommand{ + Base: argparser.Base{ + Globals: &g, + }, + Manifest: manifest.Data{ + Flag: manifest.Flag{ + ServiceID: "123", + }, + }, + ServiceVersion: argparser.OptionalServiceVersion{ + OptionalString: argparser.OptionalString{Value: "1"}, + }, + AutoClone: argparser.OptionalAutoClone{ + OptionalBool: argparser.OptionalBool{ + Optional: argparser.Optional{ + WasSet: true, + }, + Value: true, + }, + }, + EndpointName: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "logs"}, + Container: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "container"}, + AccountName: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "account"}, + SASToken: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "token"}, + Path: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "/log"}, + Period: argparser.OptionalInt{Optional: argparser.Optional{WasSet: true}, Value: 3600}, + Format: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: `%h %l %u %t "%r" %>s %b`}, + FormatVersion: argparser.OptionalInt{Optional: argparser.Optional{WasSet: true}, Value: 2}, + ResponseCondition: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "Prevent default logging"}, + TimestampFormat: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "%Y-%m-%dT%H:%M:%S.000"}, + Placement: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "none"}, + MessageType: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "classic"}, + PublicKey: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: pgpPublicKey()}, + CompressionCodec: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "zstd"}, + } +} + +func createCommandMissingServiceID() *azureblob.CreateCommand { + res := createCommandAll() + res.Manifest = manifest.Data{} + return res +} + +func updateCommandNoUpdates() *azureblob.UpdateCommand { + var b bytes.Buffer + + g := global.Data{ + Config: config.File{}, + Env: config.Environment{}, + Output: &b, + } + + return &azureblob.UpdateCommand{ + Base: argparser.Base{ + Globals: &g, + }, + Manifest: manifest.Data{ + Flag: manifest.Flag{ + ServiceID: "123", + }, + }, + EndpointName: "logs", + ServiceVersion: argparser.OptionalServiceVersion{ + OptionalString: argparser.OptionalString{Value: "1"}, + }, + AutoClone: argparser.OptionalAutoClone{ + OptionalBool: argparser.OptionalBool{ + Optional: argparser.Optional{ + WasSet: true, + }, + Value: true, + }, + }, + } +} + +func updateCommandAll() *azureblob.UpdateCommand { + var b bytes.Buffer + + g := global.Data{ + Config: config.File{}, + Env: config.Environment{}, + Output: &b, + } + + return &azureblob.UpdateCommand{ + Base: argparser.Base{ + Globals: &g, + }, + Manifest: manifest.Data{ + Flag: manifest.Flag{ + ServiceID: "123", + }, + }, + EndpointName: "logs", + ServiceVersion: argparser.OptionalServiceVersion{ + OptionalString: argparser.OptionalString{Value: "1"}, + }, + AutoClone: argparser.OptionalAutoClone{ + OptionalBool: argparser.OptionalBool{ + Optional: argparser.Optional{ + WasSet: true, + }, + Value: true, + }, + }, + NewName: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new1"}, + Container: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new2"}, + AccountName: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new3"}, + SASToken: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new4"}, + Path: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new5"}, + Period: argparser.OptionalInt{Optional: argparser.Optional{WasSet: true}, Value: 3601}, + GzipLevel: argparser.OptionalInt{Optional: argparser.Optional{WasSet: true}, Value: 0}, + Format: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new6"}, + FormatVersion: argparser.OptionalInt{Optional: argparser.Optional{WasSet: true}, Value: 3}, + ResponseCondition: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new7"}, + MessageType: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new8"}, + TimestampFormat: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new9"}, + Placement: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new10"}, + PublicKey: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new11"}, + CompressionCodec: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new12"}, + } +} + +func updateCommandMissingServiceID() *azureblob.UpdateCommand { + res := updateCommandAll() + res.Manifest = manifest.Data{} + return res +} diff --git a/pkg/commands/logging/azureblob/create.go b/pkg/commands/logging/azureblob/create.go new file mode 100644 index 000000000..28017b23e --- /dev/null +++ b/pkg/commands/logging/azureblob/create.go @@ -0,0 +1,213 @@ +package azureblob + +import ( + "fmt" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/logging/common" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/text" +) + +// CreateCommand calls the Fastly API to create an Azure Blob Storage logging endpoint. +type CreateCommand struct { + argparser.Base + Manifest manifest.Data + + // Required. + ServiceName argparser.OptionalServiceNameID + ServiceVersion argparser.OptionalServiceVersion + + // Optional. + EndpointName argparser.OptionalString + Container argparser.OptionalString + AccountName argparser.OptionalString + SASToken argparser.OptionalString + AutoClone argparser.OptionalAutoClone + Path argparser.OptionalString + Period argparser.OptionalInt + GzipLevel argparser.OptionalInt + MessageType argparser.OptionalString + Format argparser.OptionalString + FormatVersion argparser.OptionalInt + ResponseCondition argparser.OptionalString + TimestampFormat argparser.OptionalString + Placement argparser.OptionalString + PublicKey argparser.OptionalString + FileMaxBytes argparser.OptionalInt + CompressionCodec argparser.OptionalString +} + +// NewCreateCommand returns a usable command registered under the parent. +func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { + c := CreateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("create", "Create an Azure Blob Storage logging endpoint on a Fastly service version").Alias("add") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.ServiceVersion.Value, + Required: true, + }) + + // Optional. + c.CmdClause.Flag("account-name", "The unique Azure Blob Storage namespace in which your data objects are stored").Action(c.AccountName.Set).StringVar(&c.AccountName.Value) + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.AutoClone.Set, + Dst: &c.AutoClone.Value, + }) + common.CompressionCodec(c.CmdClause, &c.CompressionCodec) + c.CmdClause.Flag("container", "The name of the Azure Blob Storage container in which to store logs").Action(c.Container.Set).StringVar(&c.Container.Value) + c.CmdClause.Flag("file-max-bytes", "The maximum size of a log file in bytes").Action(c.FileMaxBytes.Set).IntVar(&c.FileMaxBytes.Value) + common.Format(c.CmdClause, &c.Format) + common.FormatVersion(c.CmdClause, &c.FormatVersion) + common.GzipLevel(c.CmdClause, &c.GzipLevel) + common.MessageType(c.CmdClause, &c.MessageType) + c.CmdClause.Flag("name", "The name of the Azure Blob Storage logging object. Used as a primary key for API access").Short('n').Action(c.EndpointName.Set).StringVar(&c.EndpointName.Value) + common.Path(c.CmdClause, &c.Path) + common.Period(c.CmdClause, &c.Period) + common.Placement(c.CmdClause, &c.Placement) + common.PublicKey(c.CmdClause, &c.PublicKey) + common.ResponseCondition(c.CmdClause, &c.ResponseCondition) + c.CmdClause.Flag("sas-token", "The Azure shared access signature providing write access to the blob service objects. Be sure to update your token before it expires or the logging functionality will not work").Action(c.SASToken.Set).StringVar(&c.SASToken.Value) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.ServiceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.ServiceName.Value, + }) + common.TimestampFormat(c.CmdClause, &c.TimestampFormat) + return &c +} + +// ConstructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *CreateCommand) ConstructInput(serviceID string, serviceVersion int) (*fastly.CreateBlobStorageInput, error) { + var input fastly.CreateBlobStorageInput + + input.ServiceID = serviceID + input.ServiceVersion = serviceVersion + + if c.EndpointName.WasSet { + input.Name = &c.EndpointName.Value + } + if c.Container.WasSet { + input.Container = &c.Container.Value + } + if c.AccountName.WasSet { + input.AccountName = &c.AccountName.Value + } + if c.SASToken.WasSet { + input.SASToken = &c.SASToken.Value + } + + // The following blocks enforces the mutual exclusivity of the + // CompressionCodec and GzipLevel flags. + if c.CompressionCodec.WasSet && c.GzipLevel.WasSet { + return nil, fmt.Errorf("error parsing arguments: the --compression-codec flag is mutually exclusive with the --gzip-level flag") + } + + if c.Path.WasSet { + input.Path = &c.Path.Value + } + if c.Period.WasSet { + input.Period = &c.Period.Value + } + if c.GzipLevel.WasSet { + input.GzipLevel = &c.GzipLevel.Value + } + if c.Format.WasSet { + input.Format = fastly.ToPointer(argparser.Content(c.Format.Value)) + } + if c.FormatVersion.WasSet { + input.FormatVersion = &c.FormatVersion.Value + } + if c.ResponseCondition.WasSet { + input.ResponseCondition = &c.ResponseCondition.Value + } + if c.MessageType.WasSet { + input.MessageType = &c.MessageType.Value + } + if c.TimestampFormat.WasSet { + input.TimestampFormat = &c.TimestampFormat.Value + } + if c.Placement.WasSet { + input.Placement = &c.Placement.Value + } + if c.PublicKey.WasSet { + input.PublicKey = &c.PublicKey.Value + } + if c.FileMaxBytes.WasSet { + input.FileMaxBytes = &c.FileMaxBytes.Value + } + if c.CompressionCodec.WasSet { + input.CompressionCodec = &c.CompressionCodec.Value + } + + return &input, nil +} + +// Exec invokes the application logic for the command. +func (c *CreateCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.AutoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.ServiceName, + ServiceVersionFlag: c.ServiceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + input, err := c.ConstructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fastly.ToValue(serviceVersion.Number), + }) + return err + } + + d, err := c.Globals.APIClient.CreateBlobStorage(input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fastly.ToValue(serviceVersion.Number), + }) + return err + } + + text.Success(out, + "Created Azure Blob Storage logging endpoint %s (service %s version %d)", + fastly.ToValue(d.Name), + fastly.ToValue(d.ServiceID), + fastly.ToValue(d.ServiceVersion), + ) + return nil +} diff --git a/pkg/commands/logging/azureblob/delete.go b/pkg/commands/logging/azureblob/delete.go new file mode 100644 index 000000000..e32206926 --- /dev/null +++ b/pkg/commands/logging/azureblob/delete.go @@ -0,0 +1,97 @@ +package azureblob + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// DeleteCommand calls the Fastly API to delete an Azure Blob Storage logging endpoint. +type DeleteCommand struct { + argparser.Base + Input fastly.DeleteBlobStorageInput + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion + autoClone argparser.OptionalAutoClone +} + +// NewDeleteCommand returns a usable command registered under the parent. +func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { + c := DeleteCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("delete", "Delete an Azure Blob Storage logging endpoint on a Fastly service version").Alias("remove") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.autoClone.Set, + Dst: &c.autoClone.Value, + }) + c.CmdClause.Flag("name", "The name of the Azure Blob Storage logging object").Short('n').Required().StringVar(&c.Input.Name) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + return &c +} + +// Exec invokes the application logic for the command. +func (c *DeleteCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.autoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + c.Input.ServiceID = serviceID + c.Input.ServiceVersion = fastly.ToValue(serviceVersion.Number) + + if err := c.Globals.APIClient.DeleteBlobStorage(&c.Input); err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fastly.ToValue(serviceVersion.Number), + }) + return err + } + + text.Success(out, "Deleted Azure Blob Storage logging endpoint %s (service %s version %d)", c.Input.Name, c.Input.ServiceID, c.Input.ServiceVersion) + return nil +} diff --git a/pkg/commands/logging/azureblob/describe.go b/pkg/commands/logging/azureblob/describe.go new file mode 100644 index 000000000..c42218fe1 --- /dev/null +++ b/pkg/commands/logging/azureblob/describe.go @@ -0,0 +1,122 @@ +package azureblob + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// DescribeCommand calls the Fastly API to describe an Azure Blob Storage logging endpoint. +type DescribeCommand struct { + argparser.Base + argparser.JSONOutput + + Input fastly.GetBlobStorageInput + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion +} + +// NewDescribeCommand returns a usable command registered under the parent. +func NewDescribeCommand(parent argparser.Registerer, g *global.Data) *DescribeCommand { + c := DescribeCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("describe", "Show detailed information about an Azure Blob Storage logging endpoint on a Fastly service version").Alias("get") + + // Required. + c.CmdClause.Flag("name", "The name of the Azure Blob Storage logging object").Short('n').Required().StringVar(&c.Input.Name) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + return &c +} + +// Exec invokes the application logic for the command. +func (c *DescribeCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fsterr.ServiceVersion(serviceVersion), + }) + return err + } + + c.Input.ServiceID = serviceID + c.Input.ServiceVersion = fastly.ToValue(serviceVersion.Number) + + o, err := c.Globals.APIClient.GetBlobStorage(&c.Input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fastly.ToValue(serviceVersion.Number), + }) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + lines := text.Lines{ + "Account name": fastly.ToValue(o.AccountName), + "Compression codec": fastly.ToValue(o.CompressionCodec), + "Container": fastly.ToValue(o.Container), + "File max bytes": fastly.ToValue(o.FileMaxBytes), + "Format version": fastly.ToValue(o.FormatVersion), + "Format": fastly.ToValue(o.Format), + "GZip level": fastly.ToValue(o.GzipLevel), + "Message type": fastly.ToValue(o.MessageType), + "Name": fastly.ToValue(o.Name), + "Path": fastly.ToValue(o.Path), + "Period": fastly.ToValue(o.Period), + "Placement": fastly.ToValue(o.Placement), + "Public key": fastly.ToValue(o.PublicKey), + "Response condition": fastly.ToValue(o.ResponseCondition), + "SAS token": fastly.ToValue(o.SASToken), + "Timestamp format": fastly.ToValue(o.TimestampFormat), + "Version": fastly.ToValue(o.ServiceVersion), + } + if !c.Globals.Verbose() { + lines["Service ID"] = fastly.ToValue(o.ServiceID) + } + text.PrintLines(out, lines) + + return nil +} diff --git a/pkg/logging/azureblob/doc.go b/pkg/commands/logging/azureblob/doc.go similarity index 100% rename from pkg/logging/azureblob/doc.go rename to pkg/commands/logging/azureblob/doc.go diff --git a/pkg/commands/logging/azureblob/list.go b/pkg/commands/logging/azureblob/list.go new file mode 100644 index 000000000..a3bd8540b --- /dev/null +++ b/pkg/commands/logging/azureblob/list.go @@ -0,0 +1,136 @@ +package azureblob + +import ( + "fmt" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// ListCommand calls the Fastly API to list Azure Blob Storage logging endpoints. +type ListCommand struct { + argparser.Base + argparser.JSONOutput + + Input fastly.ListBlobStoragesInput + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion +} + +// NewListCommand returns a usable command registered under the parent. +func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { + c := ListCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("list", "List Azure Blob Storage logging endpoints on a Fastly service version") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + return &c +} + +// Exec invokes the application logic for the command. +func (c *ListCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fsterr.ServiceVersion(serviceVersion), + }) + return err + } + + c.Input.ServiceID = serviceID + c.Input.ServiceVersion = fastly.ToValue(serviceVersion.Number) + + o, err := c.Globals.APIClient.ListBlobStorages(&c.Input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fastly.ToValue(serviceVersion.Number), + }) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + if !c.Globals.Verbose() { + tw := text.NewTable(out) + tw.AddHeader("SERVICE", "VERSION", "NAME") + for _, azureblob := range o { + tw.AddLine( + fastly.ToValue(azureblob.ServiceID), + fastly.ToValue(azureblob.ServiceVersion), + fastly.ToValue(azureblob.Name), + ) + } + tw.Print() + return nil + } + + fmt.Fprintf(out, "Version: %d\n", c.Input.ServiceVersion) + for i, azureblob := range o { + fmt.Fprintf(out, "\tBlobStorage %d/%d\n", i+1, len(o)) + fmt.Fprintf(out, "\t\tService ID: %s\n", fastly.ToValue(azureblob.ServiceID)) + fmt.Fprintf(out, "\t\tVersion: %d\n", fastly.ToValue(azureblob.ServiceVersion)) + fmt.Fprintf(out, "\t\tName: %s\n", fastly.ToValue(azureblob.Name)) + fmt.Fprintf(out, "\t\tContainer: %s\n", fastly.ToValue(azureblob.Container)) + fmt.Fprintf(out, "\t\tAccount name: %s\n", fastly.ToValue(azureblob.AccountName)) + fmt.Fprintf(out, "\t\tSAS token: %s\n", fastly.ToValue(azureblob.SASToken)) + fmt.Fprintf(out, "\t\tPath: %s\n", fastly.ToValue(azureblob.Path)) + fmt.Fprintf(out, "\t\tPeriod: %d\n", fastly.ToValue(azureblob.Period)) + fmt.Fprintf(out, "\t\tGZip level: %d\n", fastly.ToValue(azureblob.GzipLevel)) + fmt.Fprintf(out, "\t\tFormat: %s\n", fastly.ToValue(azureblob.Format)) + fmt.Fprintf(out, "\t\tFormat version: %d\n", fastly.ToValue(azureblob.FormatVersion)) + fmt.Fprintf(out, "\t\tResponse condition: %s\n", fastly.ToValue(azureblob.ResponseCondition)) + fmt.Fprintf(out, "\t\tMessage type: %s\n", fastly.ToValue(azureblob.MessageType)) + fmt.Fprintf(out, "\t\tTimestamp format: %s\n", fastly.ToValue(azureblob.TimestampFormat)) + fmt.Fprintf(out, "\t\tPlacement: %s\n", fastly.ToValue(azureblob.Placement)) + fmt.Fprintf(out, "\t\tPublic key: %s\n", fastly.ToValue(azureblob.PublicKey)) + fmt.Fprintf(out, "\t\tFile max bytes: %d\n", fastly.ToValue(azureblob.FileMaxBytes)) + fmt.Fprintf(out, "\t\tCompression codec: %s\n", fastly.ToValue(azureblob.CompressionCodec)) + } + fmt.Fprintln(out) + + return nil +} diff --git a/pkg/commands/logging/azureblob/root.go b/pkg/commands/logging/azureblob/root.go new file mode 100644 index 000000000..2da20ff59 --- /dev/null +++ b/pkg/commands/logging/azureblob/root.go @@ -0,0 +1,31 @@ +package azureblob + +import ( + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" +) + +// RootCommand is the parent command for all subcommands in this package. +// It should be installed under the primary root command. +type RootCommand struct { + argparser.Base + // no flags +} + +// CommandName is the string to be used to invoke this command. +const CommandName = "azureblob" + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent argparser.Registerer, g *global.Data) *RootCommand { + var c RootCommand + c.Globals = g + c.CmdClause = parent.Command(CommandName, "Manipulate Fastly service version Azure Blob Storage logging endpoints") + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { + panic("unreachable") +} diff --git a/pkg/commands/logging/azureblob/update.go b/pkg/commands/logging/azureblob/update.go new file mode 100644 index 000000000..b34018278 --- /dev/null +++ b/pkg/commands/logging/azureblob/update.go @@ -0,0 +1,211 @@ +package azureblob + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/logging/common" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/text" +) + +// UpdateCommand calls the Fastly API to update an Azure Blob Storage logging endpoint. +type UpdateCommand struct { + argparser.Base + Manifest manifest.Data + + // Required. + EndpointName string + ServiceName argparser.OptionalServiceNameID + ServiceVersion argparser.OptionalServiceVersion + + // Optional. + AutoClone argparser.OptionalAutoClone + NewName argparser.OptionalString + AccountName argparser.OptionalString + Container argparser.OptionalString + SASToken argparser.OptionalString + Path argparser.OptionalString + Period argparser.OptionalInt + GzipLevel argparser.OptionalInt + MessageType argparser.OptionalString + Format argparser.OptionalString + FormatVersion argparser.OptionalInt + ResponseCondition argparser.OptionalString + TimestampFormat argparser.OptionalString + Placement argparser.OptionalString + PublicKey argparser.OptionalString + FileMaxBytes argparser.OptionalInt + CompressionCodec argparser.OptionalString +} + +// NewUpdateCommand returns a usable command registered under the parent. +func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { + c := UpdateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("update", "Update an Azure Blob Storage logging endpoint on a Fastly service version") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.ServiceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.AutoClone.Set, + Dst: &c.AutoClone.Value, + }) + c.CmdClause.Flag("name", "The name of the Azure Blob Storage logging object").Short('n').Required().StringVar(&c.EndpointName) + + // Optional. + c.CmdClause.Flag("account-name", "The unique Azure Blob Storage namespace in which your data objects are stored").Action(c.AccountName.Set).StringVar(&c.AccountName.Value) + common.CompressionCodec(c.CmdClause, &c.CompressionCodec) + c.CmdClause.Flag("container", "The name of the Azure Blob Storage container in which to store logs").Action(c.Container.Set).StringVar(&c.Container.Value) + c.CmdClause.Flag("file-max-bytes", "The maximum size of a log file in bytes").Action(c.FileMaxBytes.Set).IntVar(&c.FileMaxBytes.Value) + common.Format(c.CmdClause, &c.Format) + common.FormatVersion(c.CmdClause, &c.FormatVersion) + common.GzipLevel(c.CmdClause, &c.GzipLevel) + common.MessageType(c.CmdClause, &c.MessageType) + c.CmdClause.Flag("new-name", "New name of the Azure Blob Storage logging object").Action(c.NewName.Set).StringVar(&c.NewName.Value) + common.Path(c.CmdClause, &c.Path) + common.Period(c.CmdClause, &c.Period) + common.Placement(c.CmdClause, &c.Placement) + common.PublicKey(c.CmdClause, &c.PublicKey) + common.ResponseCondition(c.CmdClause, &c.ResponseCondition) + c.CmdClause.Flag("sas-token", "The Azure shared access signature providing write access to the blob service objects. Be sure to update your token before it expires or the logging functionality will not work").Action(c.SASToken.Set).StringVar(&c.SASToken.Value) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.ServiceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.ServiceName.Value, + }) + common.TimestampFormat(c.CmdClause, &c.TimestampFormat) + return &c +} + +// ConstructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *UpdateCommand) ConstructInput(serviceID string, serviceVersion int) (*fastly.UpdateBlobStorageInput, error) { + input := fastly.UpdateBlobStorageInput{ + ServiceID: serviceID, + ServiceVersion: serviceVersion, + Name: c.EndpointName, + } + + // Set new values if set by user. + if c.NewName.WasSet { + input.NewName = &c.NewName.Value + } + if c.Path.WasSet { + input.Path = &c.Path.Value + } + if c.AccountName.WasSet { + input.AccountName = &c.AccountName.Value + } + if c.Container.WasSet { + input.Container = &c.Container.Value + } + if c.SASToken.WasSet { + input.SASToken = &c.SASToken.Value + } + if c.Period.WasSet { + input.Period = &c.Period.Value + } + if c.GzipLevel.WasSet { + input.GzipLevel = &c.GzipLevel.Value + } + if c.Format.WasSet { + input.Format = fastly.ToPointer(argparser.Content(c.Format.Value)) + } + if c.FormatVersion.WasSet { + input.FormatVersion = &c.FormatVersion.Value + } + if c.ResponseCondition.WasSet { + input.ResponseCondition = &c.ResponseCondition.Value + } + if c.MessageType.WasSet { + input.MessageType = &c.MessageType.Value + } + if c.TimestampFormat.WasSet { + input.TimestampFormat = &c.TimestampFormat.Value + } + if c.Placement.WasSet { + input.Placement = &c.Placement.Value + } + if c.PublicKey.WasSet { + input.PublicKey = &c.PublicKey.Value + } + if c.FileMaxBytes.WasSet { + input.FileMaxBytes = &c.FileMaxBytes.Value + } + if c.CompressionCodec.WasSet { + input.CompressionCodec = &c.CompressionCodec.Value + } + + return &input, nil +} + +// Exec invokes the application logic for the command. +func (c *UpdateCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.AutoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.ServiceName, + ServiceVersionFlag: c.ServiceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + input, err := c.ConstructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fastly.ToValue(serviceVersion.Number), + }) + return err + } + + azureblob, err := c.Globals.APIClient.UpdateBlobStorage(input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fastly.ToValue(serviceVersion.Number), + }) + return err + } + + text.Success(out, + "Updated Azure Blob Storage logging endpoint %s (service %s version %d)", + fastly.ToValue(azureblob.Name), + fastly.ToValue(azureblob.ServiceID), + fastly.ToValue(azureblob.ServiceVersion), + ) + return nil +} diff --git a/pkg/commands/logging/bigquery/bigquery_integration_test.go b/pkg/commands/logging/bigquery/bigquery_integration_test.go new file mode 100644 index 000000000..56a54d86c --- /dev/null +++ b/pkg/commands/logging/bigquery/bigquery_integration_test.go @@ -0,0 +1,437 @@ +package bigquery_test + +import ( + "bytes" + "errors" + "io" + "strings" + "testing" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/app" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" +) + +func TestBigQueryCreate(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging bigquery create --service-id 123 --version 1 --name log --project-id project123 --dataset logs --table logs --user user@domain.com --secret-key `\"-----BEGIN RSA PRIVATE KEY-----MIIEogIBAAKCA\"` --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + CreateBigQueryFn: createBigQueryOK, + }, + wantOutput: "Created BigQuery logging endpoint log (service 123 version 4)", + }, + { + args: args("logging bigquery create --service-id 123 --version 1 --name log --project-id project123 --dataset logs --table logs --user user@domain.com --secret-key `\"-----BEGIN RSA PRIVATE KEY-----MIIEogIBAAKCA\"` --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + CreateBigQueryFn: createBigQueryError, + }, + wantError: errTest.Error(), + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertStringContains(t, stdout.String(), testcase.wantOutput) + }) + } +} + +func TestBigQueryList(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging bigquery list --service-id 123 --version 1"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListBigQueriesFn: listBigQueriesOK, + }, + wantOutput: listBigQueriesShortOutput, + }, + { + args: args("logging bigquery list --service-id 123 --version 1 --verbose"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListBigQueriesFn: listBigQueriesOK, + }, + wantOutput: listBigQueriesVerboseOutput, + }, + { + args: args("logging bigquery list --service-id 123 --version 1 -v"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListBigQueriesFn: listBigQueriesOK, + }, + wantOutput: listBigQueriesVerboseOutput, + }, + { + args: args("logging bigquery --verbose list --service-id 123 --version 1"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListBigQueriesFn: listBigQueriesOK, + }, + wantOutput: listBigQueriesVerboseOutput, + }, + { + args: args("logging -v bigquery list --service-id 123 --version 1"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListBigQueriesFn: listBigQueriesOK, + }, + wantOutput: listBigQueriesVerboseOutput, + }, + { + args: args("logging bigquery list --service-id 123 --version 1"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListBigQueriesFn: listBigQueriesError, + }, + wantError: errTest.Error(), + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertString(t, testcase.wantOutput, stdout.String()) + }) + } +} + +func TestBigQueryDescribe(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging bigquery describe --service-id 123 --version 1"), + wantError: "error parsing arguments: required flag --name not provided", + }, + { + args: args("logging bigquery describe --service-id 123 --version 1 --name logs"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + GetBigQueryFn: getBigQueryError, + }, + wantError: errTest.Error(), + }, + { + args: args("logging bigquery describe --service-id 123 --version 1 --name logs"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + GetBigQueryFn: getBigQueryOK, + }, + wantOutput: describeBigQueryOutput, + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertString(t, testcase.wantOutput, stdout.String()) + }) + } +} + +func TestBigQueryUpdate(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging bigquery update --service-id 123 --version 1 --new-name log --project-id project123 --dataset logs --table logs --user user@domain.com --secret-key `\"-----BEGIN RSA PRIVATE KEY-----MIIEogIBAAKCA\"`"), + wantError: "error parsing arguments: required flag --name not provided", + }, + { + args: args("logging bigquery update --service-id 123 --version 1 --name logs --new-name log --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + UpdateBigQueryFn: updateBigQueryError, + }, + wantError: errTest.Error(), + }, + { + args: args("logging bigquery update --service-id 123 --version 1 --name logs --new-name log --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + UpdateBigQueryFn: updateBigQueryOK, + }, + wantOutput: "Updated BigQuery logging endpoint log (service 123 version 4)", + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertStringContains(t, stdout.String(), testcase.wantOutput) + }) + } +} + +func TestBigQueryDelete(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging bigquery delete --service-id 123 --version 1"), + wantError: "error parsing arguments: required flag --name not provided", + }, + { + args: args("logging bigquery delete --service-id 123 --version 1 --name logs --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + DeleteBigQueryFn: deleteBigQueryError, + }, + wantError: errTest.Error(), + }, + { + args: args("logging bigquery delete --service-id 123 --version 1 --name logs --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + DeleteBigQueryFn: deleteBigQueryOK, + }, + wantOutput: "Deleted BigQuery logging endpoint logs (service 123 version 4)", + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertStringContains(t, stdout.String(), testcase.wantOutput) + }) + } +} + +var errTest = errors.New("fixture error") + +func createBigQueryOK(i *fastly.CreateBigQueryInput) (*fastly.BigQuery, error) { + return &fastly.BigQuery{ + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: i.Name, + }, nil +} + +func createBigQueryError(_ *fastly.CreateBigQueryInput) (*fastly.BigQuery, error) { + return nil, errTest +} + +func listBigQueriesOK(i *fastly.ListBigQueriesInput) ([]*fastly.BigQuery, error) { + return []*fastly.BigQuery{ + { + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("logs"), + ProjectID: fastly.ToPointer("my-project"), + Dataset: fastly.ToPointer("raw-logs"), + Table: fastly.ToPointer("logs"), + User: fastly.ToPointer("service-account@domain.com"), + AccountName: fastly.ToPointer("none"), + SecretKey: fastly.ToPointer("-----BEGIN RSA PRIVATE KEY-----MIIEogIBAAKCA"), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + Template: fastly.ToPointer("%Y%m%d"), + Placement: fastly.ToPointer("none"), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + }, + { + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("analytics"), + ProjectID: fastly.ToPointer("my-project"), + Dataset: fastly.ToPointer("analytics"), + Table: fastly.ToPointer("logs"), + User: fastly.ToPointer("service-account@domain.com"), + AccountName: fastly.ToPointer("none"), + SecretKey: fastly.ToPointer("-----BEGIN RSA PRIVATE KEY-----MIIEogIBAAKCA"), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + Template: fastly.ToPointer("%Y%m%d"), + Placement: fastly.ToPointer("none"), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + }, + }, nil +} + +func listBigQueriesError(_ *fastly.ListBigQueriesInput) ([]*fastly.BigQuery, error) { + return nil, errTest +} + +var listBigQueriesShortOutput = strings.TrimSpace(` +SERVICE VERSION NAME +123 1 logs +123 1 analytics +`) + "\n" + +var listBigQueriesVerboseOutput = strings.TrimSpace(` +Fastly API endpoint: https://api.fastly.com +Fastly API token provided via config file (profile: user) + +Service ID (via --service-id): 123 + +Version: 1 + BigQuery 1/2 + Service ID: 123 + Version: 1 + Name: logs + Format: %h %l %u %t "%r" %>s %b + User: service-account@domain.com + Account name: none + Project ID: my-project + Dataset: raw-logs + Table: logs + Template suffix: %Y%m%d + Secret key: -----BEGIN RSA PRIVATE KEY-----MIIEogIBAAKCA + Response condition: Prevent default logging + Placement: none + Format version: 0 + BigQuery 2/2 + Service ID: 123 + Version: 1 + Name: analytics + Format: %h %l %u %t "%r" %>s %b + User: service-account@domain.com + Account name: none + Project ID: my-project + Dataset: analytics + Table: logs + Template suffix: %Y%m%d + Secret key: -----BEGIN RSA PRIVATE KEY-----MIIEogIBAAKCA + Response condition: Prevent default logging + Placement: none + Format version: 0 +`) + "\n\n" + +func getBigQueryOK(i *fastly.GetBigQueryInput) (*fastly.BigQuery, error) { + return &fastly.BigQuery{ + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("logs"), + ProjectID: fastly.ToPointer("my-project"), + Dataset: fastly.ToPointer("raw-logs"), + Table: fastly.ToPointer("logs"), + User: fastly.ToPointer("service-account@domain.com"), + AccountName: fastly.ToPointer("none"), + SecretKey: fastly.ToPointer("-----BEGIN RSA PRIVATE KEY-----MIIEogIBAAKCA"), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + Template: fastly.ToPointer("%Y%m%d"), + Placement: fastly.ToPointer("none"), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + }, nil +} + +func getBigQueryError(_ *fastly.GetBigQueryInput) (*fastly.BigQuery, error) { + return nil, errTest +} + +var describeBigQueryOutput = "\n" + strings.TrimSpace(` +Account name: none +Dataset: raw-logs +Format: %h %l %u %t "%r" %>s %b +Format version: 0 +Name: logs +Placement: none +Project ID: my-project +Response condition: Prevent default logging +Secret key: -----BEGIN RSA PRIVATE KEY-----MIIEogIBAAKCA +Service ID: 123 +Table: logs +Template suffix: %Y%m%d +User: service-account@domain.com +Version: 1 +`) + "\n" + +func updateBigQueryOK(i *fastly.UpdateBigQueryInput) (*fastly.BigQuery, error) { + return &fastly.BigQuery{ + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("log"), + ProjectID: fastly.ToPointer("my-project"), + Dataset: fastly.ToPointer("raw-logs"), + Table: fastly.ToPointer("logs"), + User: fastly.ToPointer("service-account@domain.com"), + SecretKey: fastly.ToPointer("-----BEGIN RSA PRIVATE KEY-----MIIEogIBAAKCA"), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + Template: fastly.ToPointer("%Y%m%d"), + Placement: fastly.ToPointer("none"), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + }, nil +} + +func updateBigQueryError(_ *fastly.UpdateBigQueryInput) (*fastly.BigQuery, error) { + return nil, errTest +} + +func deleteBigQueryOK(_ *fastly.DeleteBigQueryInput) error { + return nil +} + +func deleteBigQueryError(_ *fastly.DeleteBigQueryInput) error { + return errTest +} diff --git a/pkg/commands/logging/bigquery/bigquery_test.go b/pkg/commands/logging/bigquery/bigquery_test.go new file mode 100644 index 000000000..9199ffd84 --- /dev/null +++ b/pkg/commands/logging/bigquery/bigquery_test.go @@ -0,0 +1,364 @@ +package bigquery_test + +import ( + "bytes" + "testing" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/logging/bigquery" + "github.com/fastly/cli/pkg/config" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" +) + +func TestCreateBigQueryInput(t *testing.T) { + for _, testcase := range []struct { + name string + cmd *bigquery.CreateCommand + want *fastly.CreateBigQueryInput + wantError string + }{ + { + name: "required values set flag serviceID", + cmd: createCommandRequired(), + want: &fastly.CreateBigQueryInput{ + ServiceID: "123", + ServiceVersion: 4, + Name: fastly.ToPointer("log"), + ProjectID: fastly.ToPointer("123"), + Dataset: fastly.ToPointer("dataset"), + Table: fastly.ToPointer("table"), + User: fastly.ToPointer("user"), + SecretKey: fastly.ToPointer("-----BEGIN PRIVATE KEY-----foo"), + }, + }, + { + name: "all values set flag serviceID", + cmd: createCommandAll(), + want: &fastly.CreateBigQueryInput{ + ServiceID: "123", + ServiceVersion: 4, + Name: fastly.ToPointer("log"), + ProjectID: fastly.ToPointer("123"), + Dataset: fastly.ToPointer("dataset"), + Table: fastly.ToPointer("table"), + Template: fastly.ToPointer("template"), + User: fastly.ToPointer("user"), + SecretKey: fastly.ToPointer("-----BEGIN PRIVATE KEY-----foo"), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + Placement: fastly.ToPointer("none"), + FormatVersion: fastly.ToPointer(2), + }, + }, + { + name: "error missing serviceID", + cmd: createCommandMissingServiceID(), + want: nil, + wantError: errors.ErrNoServiceID.Error(), + }, + } { + t.Run(testcase.name, func(t *testing.T) { + var bs []byte + out := bytes.NewBuffer(bs) + verboseMode := true + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + AutoCloneFlag: testcase.cmd.AutoClone, + APIClient: testcase.cmd.Globals.APIClient, + Manifest: testcase.cmd.Manifest, + Out: out, + ServiceVersionFlag: testcase.cmd.ServiceVersion, + VerboseMode: verboseMode, + }) + + switch { + case err != nil && testcase.wantError == "": + t.Fatalf("unexpected error getting service details: %v", err) + return + case err != nil && testcase.wantError != "": + testutil.AssertErrorContains(t, err, testcase.wantError) + return + case err == nil && testcase.wantError != "": + t.Fatalf("expected error, have nil (service details: %s, %d)", serviceID, serviceVersion.Number) + case err == nil && testcase.wantError == "": + have, err := testcase.cmd.ConstructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertEqual(t, testcase.want, have) + } + }) + } +} + +func TestUpdateBigQueryInput(t *testing.T) { + scenarios := []struct { + name string + cmd *bigquery.UpdateCommand + api mock.API + want *fastly.UpdateBigQueryInput + wantError string + }{ + { + name: "no updates", + cmd: updateCommandNoUpdates(), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + GetBigQueryFn: getBigQueryOK, + }, + want: &fastly.UpdateBigQueryInput{ + ServiceID: "123", + ServiceVersion: 4, + Name: "log", + }, + }, + { + name: "all values set flag serviceID", + cmd: updateCommandAll(), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + GetBigQueryFn: getBigQueryOK, + }, + want: &fastly.UpdateBigQueryInput{ + ServiceID: "123", + ServiceVersion: 4, + Name: "log", + NewName: fastly.ToPointer("new1"), + ProjectID: fastly.ToPointer("new2"), + Dataset: fastly.ToPointer("new3"), + Table: fastly.ToPointer("new4"), + User: fastly.ToPointer("new5"), + SecretKey: fastly.ToPointer("new6"), + Template: fastly.ToPointer("new7"), + ResponseCondition: fastly.ToPointer("new8"), + Placement: fastly.ToPointer("new9"), + Format: fastly.ToPointer("new10"), + FormatVersion: fastly.ToPointer(3), + }, + }, + { + name: "error missing serviceID", + cmd: updateCommandMissingServiceID(), + want: nil, + wantError: errors.ErrNoServiceID.Error(), + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(testcase.name, func(t *testing.T) { + testcase.cmd.Globals.APIClient = testcase.api + + var bs []byte + out := bytes.NewBuffer(bs) + verboseMode := true + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + AutoCloneFlag: testcase.cmd.AutoClone, + APIClient: testcase.api, + Manifest: testcase.cmd.Manifest, + Out: out, + ServiceVersionFlag: testcase.cmd.ServiceVersion, + VerboseMode: verboseMode, + }) + + switch { + case err != nil && testcase.wantError == "": + t.Fatalf("unexpected error getting service details: %v", err) + return + case err != nil && testcase.wantError != "": + testutil.AssertErrorContains(t, err, testcase.wantError) + return + case err == nil && testcase.wantError != "": + t.Fatalf("expected error, have nil (service details: %s, %d)", serviceID, serviceVersion.Number) + case err == nil && testcase.wantError == "": + have, err := testcase.cmd.ConstructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertEqual(t, testcase.want, have) + } + }) + } +} + +func createCommandRequired() *bigquery.CreateCommand { + var b bytes.Buffer + + g := global.Data{ + Config: config.File{}, + Env: config.Environment{}, + Output: &b, + } + g.APIClient, _ = mock.APIClient(mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + })("token", "endpoint", false) + + return &bigquery.CreateCommand{ + Base: argparser.Base{ + Globals: &g, + }, + Manifest: manifest.Data{ + Flag: manifest.Flag{ + ServiceID: "123", + }, + }, + ServiceVersion: argparser.OptionalServiceVersion{ + OptionalString: argparser.OptionalString{Value: "1"}, + }, + AutoClone: argparser.OptionalAutoClone{ + OptionalBool: argparser.OptionalBool{ + Optional: argparser.Optional{ + WasSet: true, + }, + Value: true, + }, + }, + EndpointName: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "log"}, + ProjectID: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "123"}, + Dataset: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "dataset"}, + Table: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "table"}, + User: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "user"}, + SecretKey: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "-----BEGIN PRIVATE KEY-----foo"}, + } +} + +func createCommandAll() *bigquery.CreateCommand { + var b bytes.Buffer + + g := global.Data{ + Config: config.File{}, + Env: config.Environment{}, + Output: &b, + } + g.APIClient, _ = mock.APIClient(mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + })("token", "endpoint", false) + + return &bigquery.CreateCommand{ + Base: argparser.Base{ + Globals: &g, + }, + Manifest: manifest.Data{ + Flag: manifest.Flag{ + ServiceID: "123", + }, + }, + ServiceVersion: argparser.OptionalServiceVersion{ + OptionalString: argparser.OptionalString{Value: "1"}, + }, + AutoClone: argparser.OptionalAutoClone{ + OptionalBool: argparser.OptionalBool{ + Optional: argparser.Optional{ + WasSet: true, + }, + Value: true, + }, + }, + EndpointName: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "log"}, + ProjectID: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "123"}, + Dataset: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "dataset"}, + Table: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "table"}, + User: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "user"}, + SecretKey: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "-----BEGIN PRIVATE KEY-----foo"}, + Template: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "template"}, + ResponseCondition: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "Prevent default logging"}, + Placement: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "none"}, + Format: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: `%h %l %u %t "%r" %>s %b`}, + FormatVersion: argparser.OptionalInt{Optional: argparser.Optional{WasSet: true}, Value: 2}, + } +} + +func createCommandMissingServiceID() *bigquery.CreateCommand { + res := createCommandAll() + res.Manifest = manifest.Data{} + return res +} + +func updateCommandNoUpdates() *bigquery.UpdateCommand { + var b bytes.Buffer + + g := global.Data{ + Config: config.File{}, + Env: config.Environment{}, + Output: &b, + } + + return &bigquery.UpdateCommand{ + Base: argparser.Base{ + Globals: &g, + }, + Manifest: manifest.Data{ + Flag: manifest.Flag{ + ServiceID: "123", + }, + }, + EndpointName: "log", + ServiceVersion: argparser.OptionalServiceVersion{ + OptionalString: argparser.OptionalString{Value: "1"}, + }, + AutoClone: argparser.OptionalAutoClone{ + OptionalBool: argparser.OptionalBool{ + Optional: argparser.Optional{ + WasSet: true, + }, + Value: true, + }, + }, + } +} + +func updateCommandAll() *bigquery.UpdateCommand { + var b bytes.Buffer + + g := global.Data{ + Config: config.File{}, + Env: config.Environment{}, + Output: &b, + } + + return &bigquery.UpdateCommand{ + Base: argparser.Base{ + Globals: &g, + }, + Manifest: manifest.Data{ + Flag: manifest.Flag{ + ServiceID: "123", + }, + }, + EndpointName: "log", + ServiceVersion: argparser.OptionalServiceVersion{ + OptionalString: argparser.OptionalString{Value: "1"}, + }, + AutoClone: argparser.OptionalAutoClone{ + OptionalBool: argparser.OptionalBool{ + Optional: argparser.Optional{ + WasSet: true, + }, + Value: true, + }, + }, + NewName: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new1"}, + ProjectID: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new2"}, + Dataset: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new3"}, + Table: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new4"}, + User: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new5"}, + SecretKey: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new6"}, + Template: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new7"}, + ResponseCondition: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new8"}, + Placement: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new9"}, + Format: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new10"}, + FormatVersion: argparser.OptionalInt{Optional: argparser.Optional{WasSet: true}, Value: 3}, + } +} + +func updateCommandMissingServiceID() *bigquery.UpdateCommand { + res := updateCommandAll() + res.Manifest = manifest.Data{} + return res +} diff --git a/pkg/commands/logging/bigquery/create.go b/pkg/commands/logging/bigquery/create.go new file mode 100644 index 000000000..6975c0995 --- /dev/null +++ b/pkg/commands/logging/bigquery/create.go @@ -0,0 +1,185 @@ +package bigquery + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/logging/common" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/text" +) + +// CreateCommand calls the Fastly API to create a BigQuery logging endpoint. +type CreateCommand struct { + argparser.Base + Manifest manifest.Data + + // Required. + ServiceName argparser.OptionalServiceNameID + ServiceVersion argparser.OptionalServiceVersion + + // Optional. + AccountName argparser.OptionalString + AutoClone argparser.OptionalAutoClone + Dataset argparser.OptionalString + EndpointName argparser.OptionalString // Can't shadow argparser.Base method Name(). + Format argparser.OptionalString + FormatVersion argparser.OptionalInt + Placement argparser.OptionalString + ProjectID argparser.OptionalString + ResponseCondition argparser.OptionalString + SecretKey argparser.OptionalString + Table argparser.OptionalString + Template argparser.OptionalString + User argparser.OptionalString +} + +// NewCreateCommand returns a usable command registered under the parent. +func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { + c := CreateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("create", "Create a BigQuery logging endpoint on a Fastly service version").Alias("add") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.ServiceVersion.Value, + Required: true, + }) + + // Optional. + common.AccountName(c.CmdClause, &c.AccountName) + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.AutoClone.Set, + Dst: &c.AutoClone.Value, + }) + c.CmdClause.Flag("dataset", "Your BigQuery dataset").Action(c.Dataset.Set).StringVar(&c.Dataset.Value) + common.Format(c.CmdClause, &c.Format) + common.FormatVersion(c.CmdClause, &c.FormatVersion) + c.CmdClause.Flag("name", "The name of the BigQuery logging object. Used as a primary key for API access").Short('n').Action(c.EndpointName.Set).StringVar(&c.EndpointName.Value) + common.Placement(c.CmdClause, &c.Placement) + c.CmdClause.Flag("project-id", "Your Google Cloud Platform project ID").Action(c.ProjectID.Set).StringVar(&c.ProjectID.Value) + common.ResponseCondition(c.CmdClause, &c.ResponseCondition) + c.CmdClause.Flag("secret-key", "Your Google Cloud Platform account secret key. The private_key field in your service account authentication JSON.").Action(c.SecretKey.Set).StringVar(&c.SecretKey.Value) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.ServiceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.ServiceName.Value, + }) + c.CmdClause.Flag("table", "Your BigQuery table").Action(c.Table.Set).StringVar(&c.Table.Value) + c.CmdClause.Flag("template-suffix", "BigQuery table name suffix template").Action(c.Template.Set).StringVar(&c.Template.Value) + c.CmdClause.Flag("user", "Your Google Cloud Platform service account email address. The client_email field in your service account authentication JSON.").Action(c.User.Set).StringVar(&c.User.Value) + return &c +} + +// ConstructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *CreateCommand) ConstructInput(serviceID string, serviceVersion int) (*fastly.CreateBigQueryInput, error) { + input := fastly.CreateBigQueryInput{ + ServiceID: serviceID, + ServiceVersion: serviceVersion, + } + + if c.AccountName.WasSet { + input.AccountName = &c.AccountName.Value + } + if c.Dataset.WasSet { + input.Dataset = &c.Dataset.Value + } + if c.EndpointName.WasSet { + input.Name = &c.EndpointName.Value + } + if c.Format.WasSet { + input.Format = fastly.ToPointer(argparser.Content(c.Format.Value)) + } + if c.FormatVersion.WasSet { + input.FormatVersion = &c.FormatVersion.Value + } + if c.Placement.WasSet { + input.Placement = &c.Placement.Value + } + if c.ProjectID.WasSet { + input.ProjectID = &c.ProjectID.Value + } + if c.ResponseCondition.WasSet { + input.ResponseCondition = &c.ResponseCondition.Value + } + if c.SecretKey.WasSet { + input.SecretKey = &c.SecretKey.Value + } + if c.Table.WasSet { + input.Table = &c.Table.Value + } + if c.Template.WasSet { + input.Template = &c.Template.Value + } + if c.User.WasSet { + input.User = &c.User.Value + } + + return &input, nil +} + +// Exec invokes the application logic for the command. +func (c *CreateCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.AutoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.ServiceName, + ServiceVersionFlag: c.ServiceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fsterr.ServiceVersion(serviceVersion), + }) + return err + } + + input, err := c.ConstructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fastly.ToValue(serviceVersion.Number), + }) + return err + } + + d, err := c.Globals.APIClient.CreateBigQuery(input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fastly.ToValue(serviceVersion.Number), + }) + return err + } + + text.Success(out, + "Created BigQuery logging endpoint %s (service %s version %d)", + fastly.ToValue(d.Name), + fastly.ToValue(d.ServiceID), + fastly.ToValue(d.ServiceVersion), + ) + return nil +} diff --git a/pkg/commands/logging/bigquery/delete.go b/pkg/commands/logging/bigquery/delete.go new file mode 100644 index 000000000..fbb62f8f9 --- /dev/null +++ b/pkg/commands/logging/bigquery/delete.go @@ -0,0 +1,97 @@ +package bigquery + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// DeleteCommand calls the Fastly API to delete a BigQuery logging endpoint. +type DeleteCommand struct { + argparser.Base + Input fastly.DeleteBigQueryInput + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion + autoClone argparser.OptionalAutoClone +} + +// NewDeleteCommand returns a usable command registered under the parent. +func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { + c := DeleteCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("delete", "Delete a BigQuery logging endpoint on a Fastly service version").Alias("remove") + + // Required. + c.CmdClause.Flag("name", "The name of the BigQuery logging object").Short('n').Required().StringVar(&c.Input.Name) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.autoClone.Set, + Dst: &c.autoClone.Value, + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + return &c +} + +// Exec invokes the application logic for the command. +func (c *DeleteCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.autoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + c.Input.ServiceID = serviceID + c.Input.ServiceVersion = fastly.ToValue(serviceVersion.Number) + + if err := c.Globals.APIClient.DeleteBigQuery(&c.Input); err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fastly.ToValue(serviceVersion.Number), + }) + return err + } + + text.Success(out, "Deleted BigQuery logging endpoint %s (service %s version %d)", c.Input.Name, c.Input.ServiceID, c.Input.ServiceVersion) + return nil +} diff --git a/pkg/commands/logging/bigquery/describe.go b/pkg/commands/logging/bigquery/describe.go new file mode 100644 index 000000000..07f212518 --- /dev/null +++ b/pkg/commands/logging/bigquery/describe.go @@ -0,0 +1,118 @@ +package bigquery + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// DescribeCommand calls the Fastly API to describe a BigQuery logging endpoint. +type DescribeCommand struct { + argparser.Base + argparser.JSONOutput + + Input fastly.GetBigQueryInput + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion +} + +// NewDescribeCommand returns a usable command registered under the parent. +func NewDescribeCommand(parent argparser.Registerer, g *global.Data) *DescribeCommand { + c := DescribeCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("describe", "Show detailed information about a BigQuery logging endpoint on a Fastly service version").Alias("get") + + // Required. + c.CmdClause.Flag("name", "The name of the BigQuery logging object").Short('n').Required().StringVar(&c.Input.Name) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + return &c +} + +// Exec invokes the application logic for the command. +func (c *DescribeCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fsterr.ServiceVersion(serviceVersion), + }) + return err + } + + c.Input.ServiceID = serviceID + c.Input.ServiceVersion = fastly.ToValue(serviceVersion.Number) + + o, err := c.Globals.APIClient.GetBigQuery(&c.Input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fastly.ToValue(serviceVersion.Number), + }) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + lines := text.Lines{ + "Account name": fastly.ToValue(o.AccountName), + "Dataset": fastly.ToValue(o.Dataset), + "Format version": fastly.ToValue(o.FormatVersion), + "Format": fastly.ToValue(o.Format), + "Name": fastly.ToValue(o.Name), + "Placement": fastly.ToValue(o.Placement), + "Project ID": fastly.ToValue(o.ProjectID), + "Response condition": fastly.ToValue(o.ResponseCondition), + "Secret key": fastly.ToValue(o.SecretKey), + "Table": fastly.ToValue(o.Table), + "Template suffix": fastly.ToValue(o.Template), + "User": fastly.ToValue(o.User), + "Version": fastly.ToValue(o.ServiceVersion), + } + if !c.Globals.Verbose() { + lines["Service ID"] = fastly.ToValue(o.ServiceID) + } + text.PrintLines(out, lines) + + return nil +} diff --git a/pkg/logging/bigquery/doc.go b/pkg/commands/logging/bigquery/doc.go similarity index 100% rename from pkg/logging/bigquery/doc.go rename to pkg/commands/logging/bigquery/doc.go diff --git a/pkg/commands/logging/bigquery/list.go b/pkg/commands/logging/bigquery/list.go new file mode 100644 index 000000000..71fe814b9 --- /dev/null +++ b/pkg/commands/logging/bigquery/list.go @@ -0,0 +1,132 @@ +package bigquery + +import ( + "fmt" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// ListCommand calls the Fastly API to list BigQuery logging endpoints. +type ListCommand struct { + argparser.Base + argparser.JSONOutput + + Input fastly.ListBigQueriesInput + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion +} + +// NewListCommand returns a usable command registered under the parent. +func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { + c := ListCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("list", "List BigQuery endpoints on a Fastly service version") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + return &c +} + +// Exec invokes the application logic for the command. +func (c *ListCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fsterr.ServiceVersion(serviceVersion), + }) + return err + } + + c.Input.ServiceID = serviceID + c.Input.ServiceVersion = fastly.ToValue(serviceVersion.Number) + + o, err := c.Globals.APIClient.ListBigQueries(&c.Input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fastly.ToValue(serviceVersion.Number), + }) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + if !c.Globals.Verbose() { + tw := text.NewTable(out) + tw.AddHeader("SERVICE", "VERSION", "NAME") + for _, bq := range o { + tw.AddLine( + fastly.ToValue(bq.ServiceID), + fastly.ToValue(bq.ServiceVersion), + fastly.ToValue(bq.Name), + ) + } + tw.Print() + return nil + } + + fmt.Fprintf(out, "Version: %d\n", c.Input.ServiceVersion) + for i, bq := range o { + fmt.Fprintf(out, "\tBigQuery %d/%d\n", i+1, len(o)) + fmt.Fprintf(out, "\t\tService ID: %s\n", fastly.ToValue(bq.ServiceID)) + fmt.Fprintf(out, "\t\tVersion: %d\n", fastly.ToValue(bq.ServiceVersion)) + fmt.Fprintf(out, "\t\tName: %s\n", fastly.ToValue(bq.Name)) + fmt.Fprintf(out, "\t\tFormat: %s\n", fastly.ToValue(bq.Format)) + fmt.Fprintf(out, "\t\tUser: %s\n", fastly.ToValue(bq.User)) + fmt.Fprintf(out, "\t\tAccount name: %s\n", fastly.ToValue(bq.AccountName)) + fmt.Fprintf(out, "\t\tProject ID: %s\n", fastly.ToValue(bq.ProjectID)) + fmt.Fprintf(out, "\t\tDataset: %s\n", fastly.ToValue(bq.Dataset)) + fmt.Fprintf(out, "\t\tTable: %s\n", fastly.ToValue(bq.Table)) + fmt.Fprintf(out, "\t\tTemplate suffix: %s\n", fastly.ToValue(bq.Template)) + fmt.Fprintf(out, "\t\tSecret key: %s\n", fastly.ToValue(bq.SecretKey)) + fmt.Fprintf(out, "\t\tResponse condition: %s\n", fastly.ToValue(bq.ResponseCondition)) + fmt.Fprintf(out, "\t\tPlacement: %s\n", fastly.ToValue(bq.Placement)) + fmt.Fprintf(out, "\t\tFormat version: %d\n", fastly.ToValue(bq.FormatVersion)) + } + fmt.Fprintln(out) + + return nil +} diff --git a/pkg/commands/logging/bigquery/root.go b/pkg/commands/logging/bigquery/root.go new file mode 100644 index 000000000..15e1db014 --- /dev/null +++ b/pkg/commands/logging/bigquery/root.go @@ -0,0 +1,31 @@ +package bigquery + +import ( + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" +) + +// RootCommand is the parent command for all subcommands in this package. +// It should be installed under the primary root command. +type RootCommand struct { + argparser.Base + // no flags +} + +// CommandName is the string to be used to invoke this command. +const CommandName = "bigquery" + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent argparser.Registerer, g *global.Data) *RootCommand { + var c RootCommand + c.Globals = g + c.CmdClause = parent.Command(CommandName, "Manipulate Fastly service version BigQuery logging endpoints") + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { + panic("unreachable") +} diff --git a/pkg/commands/logging/bigquery/update.go b/pkg/commands/logging/bigquery/update.go new file mode 100644 index 000000000..bdb7a47b4 --- /dev/null +++ b/pkg/commands/logging/bigquery/update.go @@ -0,0 +1,188 @@ +package bigquery + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/logging/common" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/text" +) + +// UpdateCommand calls the Fastly API to update a BigQuery logging endpoint. +type UpdateCommand struct { + argparser.Base + Manifest manifest.Data + + // Required. + EndpointName string // Can't shadow argparser.Base method Name(). + ServiceName argparser.OptionalServiceNameID + ServiceVersion argparser.OptionalServiceVersion + + // Optional. + AccountName argparser.OptionalString + AutoClone argparser.OptionalAutoClone + Dataset argparser.OptionalString + Format argparser.OptionalString + FormatVersion argparser.OptionalInt + NewName argparser.OptionalString + Placement argparser.OptionalString + ProjectID argparser.OptionalString + ResponseCondition argparser.OptionalString + SecretKey argparser.OptionalString + Table argparser.OptionalString + Template argparser.OptionalString + User argparser.OptionalString +} + +// NewUpdateCommand returns a usable command registered under the parent. +func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { + c := UpdateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("update", "Update a BigQuery logging endpoint on a Fastly service version") + + // Required. + c.CmdClause.Flag("name", "The name of the BigQuery logging object").Short('n').Required().StringVar(&c.EndpointName) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.ServiceVersion.Value, + Required: true, + }) + + // Optional. + common.AccountName(c.CmdClause, &c.AccountName) + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.AutoClone.Set, + Dst: &c.AutoClone.Value, + }) + c.CmdClause.Flag("dataset", "Your BigQuery dataset").Action(c.Dataset.Set).StringVar(&c.Dataset.Value) + common.Format(c.CmdClause, &c.Format) + common.FormatVersion(c.CmdClause, &c.FormatVersion) + c.CmdClause.Flag("new-name", "New name of the BigQuery logging object").Action(c.NewName.Set).StringVar(&c.NewName.Value) + common.Placement(c.CmdClause, &c.Placement) + c.CmdClause.Flag("project-id", "Your Google Cloud Platform project ID").Action(c.ProjectID.Set).StringVar(&c.ProjectID.Value) + common.ResponseCondition(c.CmdClause, &c.ResponseCondition) + c.CmdClause.Flag("secret-key", "Your Google Cloud Platform account secret key. The private_key field in your service account authentication JSON.").Action(c.SecretKey.Set).StringVar(&c.SecretKey.Value) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.ServiceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.ServiceName.Value, + }) + c.CmdClause.Flag("table", "Your BigQuery table").Action(c.Table.Set).StringVar(&c.Table.Value) + c.CmdClause.Flag("template-suffix", "BigQuery table name suffix template").Action(c.Template.Set).StringVar(&c.Template.Value) + c.CmdClause.Flag("user", "Your Google Cloud Platform service account email address. The client_email field in your service account authentication JSON.").Action(c.User.Set).StringVar(&c.User.Value) + return &c +} + +// ConstructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *UpdateCommand) ConstructInput(serviceID string, serviceVersion int) (*fastly.UpdateBigQueryInput, error) { + input := fastly.UpdateBigQueryInput{ + ServiceID: serviceID, + ServiceVersion: serviceVersion, + Name: c.EndpointName, + } + + if c.AccountName.WasSet { + input.AccountName = &c.AccountName.Value + } + if c.Dataset.WasSet { + input.Dataset = &c.Dataset.Value + } + if c.Format.WasSet { + input.Format = fastly.ToPointer(argparser.Content(c.Format.Value)) + } + if c.FormatVersion.WasSet { + input.FormatVersion = &c.FormatVersion.Value + } + if c.NewName.WasSet { + input.NewName = &c.NewName.Value + } + if c.Placement.WasSet { + input.Placement = &c.Placement.Value + } + if c.ProjectID.WasSet { + input.ProjectID = &c.ProjectID.Value + } + if c.ResponseCondition.WasSet { + input.ResponseCondition = &c.ResponseCondition.Value + } + if c.SecretKey.WasSet { + input.SecretKey = &c.SecretKey.Value + } + if c.Table.WasSet { + input.Table = &c.Table.Value + } + if c.Template.WasSet { + input.Template = &c.Template.Value + } + if c.User.WasSet { + input.User = &c.User.Value + } + + return &input, nil +} + +// Exec invokes the application logic for the command. +func (c *UpdateCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.AutoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.ServiceName, + ServiceVersionFlag: c.ServiceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fsterr.ServiceVersion(serviceVersion), + }) + return err + } + + input, err := c.ConstructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fastly.ToValue(serviceVersion.Number), + }) + return err + } + + bq, err := c.Globals.APIClient.UpdateBigQuery(input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fastly.ToValue(serviceVersion.Number), + }) + return err + } + + text.Success(out, + "Updated BigQuery logging endpoint %s (service %s version %d)", + fastly.ToValue(bq.Name), + fastly.ToValue(bq.ServiceID), + fastly.ToValue(bq.ServiceVersion), + ) + return nil +} diff --git a/pkg/commands/logging/cloudfiles/cloudfiles_integration_test.go b/pkg/commands/logging/cloudfiles/cloudfiles_integration_test.go new file mode 100644 index 000000000..fae0a3077 --- /dev/null +++ b/pkg/commands/logging/cloudfiles/cloudfiles_integration_test.go @@ -0,0 +1,510 @@ +package cloudfiles_test + +import ( + "bytes" + "errors" + "io" + "strings" + "testing" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/app" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" +) + +func TestCloudfilesCreate(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging cloudfiles create --service-id 123 --version 1 --name log --user username --bucket log --access-key foo --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + CreateCloudfilesFn: createCloudfilesOK, + }, + wantOutput: "Created Cloudfiles logging endpoint log (service 123 version 4)", + }, + { + args: args("logging cloudfiles create --service-id 123 --version 1 --name log --user username --bucket log --access-key foo --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + CreateCloudfilesFn: createCloudfilesError, + }, + wantError: errTest.Error(), + }, + { + args: args("logging cloudfiles create --service-id 123 --version 1 --name log --user username --bucket log --access-key foo --compression-codec zstd --gzip-level 9 --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + }, + wantError: "error parsing arguments: the --compression-codec flag is mutually exclusive with the --gzip-level flag", + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertStringContains(t, stdout.String(), testcase.wantOutput) + }) + } +} + +func TestCloudfilesList(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging cloudfiles list --service-id 123 --version 1"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListCloudfilesFn: listCloudfilesOK, + }, + wantOutput: listCloudfilesShortOutput, + }, + { + args: args("logging cloudfiles list --service-id 123 --version 1 --verbose"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListCloudfilesFn: listCloudfilesOK, + }, + wantOutput: listCloudfilesVerboseOutput, + }, + { + args: args("logging cloudfiles list --service-id 123 --version 1 -v"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListCloudfilesFn: listCloudfilesOK, + }, + wantOutput: listCloudfilesVerboseOutput, + }, + { + args: args("logging cloudfiles --verbose list --service-id 123 --version 1"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListCloudfilesFn: listCloudfilesOK, + }, + wantOutput: listCloudfilesVerboseOutput, + }, + { + args: args("logging -v cloudfiles list --service-id 123 --version 1"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListCloudfilesFn: listCloudfilesOK, + }, + wantOutput: listCloudfilesVerboseOutput, + }, + { + args: args("logging cloudfiles list --service-id 123 --version 1"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListCloudfilesFn: listCloudfilesError, + }, + wantError: errTest.Error(), + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertString(t, testcase.wantOutput, stdout.String()) + }) + } +} + +func TestCloudfilesDescribe(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging cloudfiles describe --service-id 123 --version 1"), + wantError: "error parsing arguments: required flag --name not provided", + }, + { + args: args("logging cloudfiles describe --service-id 123 --version 1 --name logs"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + GetCloudfilesFn: getCloudfilesError, + }, + wantError: errTest.Error(), + }, + { + args: args("logging cloudfiles describe --service-id 123 --version 1 --name logs"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + GetCloudfilesFn: getCloudfilesOK, + }, + wantOutput: describeCloudfilesOutput, + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertString(t, testcase.wantOutput, stdout.String()) + }) + } +} + +func TestCloudfilesUpdate(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging cloudfiles update --service-id 123 --version 1 --new-name log"), + wantError: "error parsing arguments: required flag --name not provided", + }, + { + args: args("logging cloudfiles update --service-id 123 --version 1 --name logs --new-name log --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + UpdateCloudfilesFn: updateCloudfilesError, + }, + wantError: errTest.Error(), + }, + { + args: args("logging cloudfiles update --service-id 123 --version 1 --name logs --new-name log --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + UpdateCloudfilesFn: updateCloudfilesOK, + }, + wantOutput: "Updated Cloudfiles logging endpoint log (service 123 version 4)", + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertStringContains(t, stdout.String(), testcase.wantOutput) + }) + } +} + +func TestCloudfilesDelete(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging cloudfiles delete --service-id 123 --version 1"), + wantError: "error parsing arguments: required flag --name not provided", + }, + { + args: args("logging cloudfiles delete --service-id 123 --version 1 --name logs --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + DeleteCloudfilesFn: deleteCloudfilesError, + }, + wantError: errTest.Error(), + }, + { + args: args("logging cloudfiles delete --service-id 123 --version 1 --name logs --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + DeleteCloudfilesFn: deleteCloudfilesOK, + }, + wantOutput: "Deleted Cloudfiles logging endpoint logs (service 123 version 4)", + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertStringContains(t, stdout.String(), testcase.wantOutput) + }) + } +} + +var errTest = errors.New("fixture error") + +func createCloudfilesOK(i *fastly.CreateCloudfilesInput) (*fastly.Cloudfiles, error) { + s := fastly.Cloudfiles{ + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + } + + if i.Name != nil { + s.Name = i.Name + } + + return &s, nil +} + +func createCloudfilesError(_ *fastly.CreateCloudfilesInput) (*fastly.Cloudfiles, error) { + return nil, errTest +} + +func listCloudfilesOK(i *fastly.ListCloudfilesInput) ([]*fastly.Cloudfiles, error) { + return []*fastly.Cloudfiles{ + { + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("logs"), + User: fastly.ToPointer("username"), + AccessKey: fastly.ToPointer("1234"), + BucketName: fastly.ToPointer("my-logs"), + Path: fastly.ToPointer("logs/"), + Region: fastly.ToPointer("ORD"), + Placement: fastly.ToPointer("none"), + Period: fastly.ToPointer(3600), + GzipLevel: fastly.ToPointer(9), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + FormatVersion: fastly.ToPointer(2), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + MessageType: fastly.ToPointer("classic"), + TimestampFormat: fastly.ToPointer("%Y-%m-%dT%H:%M:%S.000"), + PublicKey: fastly.ToPointer(pgpPublicKey()), + }, + { + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("analytics"), + User: fastly.ToPointer("username"), + AccessKey: fastly.ToPointer("1234"), + BucketName: fastly.ToPointer("analytics"), + Path: fastly.ToPointer("logs/"), + Region: fastly.ToPointer("ORD"), + Placement: fastly.ToPointer("none"), + Period: fastly.ToPointer(86400), + GzipLevel: fastly.ToPointer(9), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + FormatVersion: fastly.ToPointer(2), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + MessageType: fastly.ToPointer("classic"), + TimestampFormat: fastly.ToPointer("%Y-%m-%dT%H:%M:%S.000"), + PublicKey: fastly.ToPointer(pgpPublicKey()), + }, + }, nil +} + +func listCloudfilesError(_ *fastly.ListCloudfilesInput) ([]*fastly.Cloudfiles, error) { + return nil, errTest +} + +var listCloudfilesShortOutput = strings.TrimSpace(` +SERVICE VERSION NAME +123 1 logs +123 1 analytics +`) + "\n" + +var listCloudfilesVerboseOutput = strings.TrimSpace(` +Fastly API endpoint: https://api.fastly.com +Fastly API token provided via config file (profile: user) + +Service ID (via --service-id): 123 + +Version: 1 + Cloudfiles 1/2 + Service ID: 123 + Version: 1 + Name: logs + User: username + Access key: 1234 + Bucket: my-logs + Path: logs/ + Region: ORD + Placement: none + Period: 3600 + GZip level: 9 + Format: %h %l %u %t "%r" %>s %b + Format version: 2 + Response condition: Prevent default logging + Message type: classic + Timestamp format: %Y-%m-%dT%H:%M:%S.000 + Public key: `+pgpPublicKey()+` + Cloudfiles 2/2 + Service ID: 123 + Version: 1 + Name: analytics + User: username + Access key: 1234 + Bucket: analytics + Path: logs/ + Region: ORD + Placement: none + Period: 86400 + GZip level: 9 + Format: %h %l %u %t "%r" %>s %b + Format version: 2 + Response condition: Prevent default logging + Message type: classic + Timestamp format: %Y-%m-%dT%H:%M:%S.000 + Public key: `+pgpPublicKey()+` +`) + "\n\n" + +func getCloudfilesOK(i *fastly.GetCloudfilesInput) (*fastly.Cloudfiles, error) { + return &fastly.Cloudfiles{ + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("logs"), + User: fastly.ToPointer("username"), + AccessKey: fastly.ToPointer("1234"), + BucketName: fastly.ToPointer("my-logs"), + Path: fastly.ToPointer("logs/"), + Region: fastly.ToPointer("ORD"), + Placement: fastly.ToPointer("none"), + Period: fastly.ToPointer(3600), + GzipLevel: fastly.ToPointer(9), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + FormatVersion: fastly.ToPointer(2), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + MessageType: fastly.ToPointer("classic"), + TimestampFormat: fastly.ToPointer("%Y-%m-%dT%H:%M:%S.000"), + PublicKey: fastly.ToPointer(pgpPublicKey()), + }, nil +} + +func getCloudfilesError(_ *fastly.GetCloudfilesInput) (*fastly.Cloudfiles, error) { + return nil, errTest +} + +var describeCloudfilesOutput = "\n" + strings.TrimSpace(` +Access key: 1234 +Bucket: my-logs +Format: %h %l %u %t "%r" %>s %b +Format version: 2 +GZip level: 9 +Message type: classic +Name: logs +Path: logs/ +Period: 3600 +Placement: none +Public key: `+pgpPublicKey()+` +Region: ORD +Response condition: Prevent default logging +Service ID: 123 +Timestamp format: %Y-%m-%dT%H:%M:%S.000 +User: username +Version: 1 +`) + "\n" + +func updateCloudfilesOK(i *fastly.UpdateCloudfilesInput) (*fastly.Cloudfiles, error) { + return &fastly.Cloudfiles{ + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("log"), + User: fastly.ToPointer("username"), + AccessKey: fastly.ToPointer("1234"), + BucketName: fastly.ToPointer("my-logs"), + Path: fastly.ToPointer("logs/"), + Region: fastly.ToPointer("ORD"), + Placement: fastly.ToPointer("none"), + Period: fastly.ToPointer(3600), + GzipLevel: fastly.ToPointer(9), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + FormatVersion: fastly.ToPointer(2), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + MessageType: fastly.ToPointer("classic"), + TimestampFormat: fastly.ToPointer("%Y-%m-%dT%H:%M:%S.000"), + PublicKey: fastly.ToPointer(pgpPublicKey()), + }, nil +} + +func updateCloudfilesError(_ *fastly.UpdateCloudfilesInput) (*fastly.Cloudfiles, error) { + return nil, errTest +} + +func deleteCloudfilesOK(_ *fastly.DeleteCloudfilesInput) error { + return nil +} + +func deleteCloudfilesError(_ *fastly.DeleteCloudfilesInput) error { + return errTest +} + +// pgpPublicKey returns a PEM encoded PGP public key suitable for testing. +func pgpPublicKey() string { + return strings.TrimSpace(`-----BEGIN PGP PUBLIC KEY BLOCK----- +mQENBFyUD8sBCACyFnB39AuuTygseek+eA4fo0cgwva6/FSjnWq7riouQee8GgQ/ +ibXTRyv4iVlwI12GswvMTIy7zNvs1R54i0qvsLr+IZ4GVGJqs6ZJnvQcqe3xPoR4 +8AnBfw90o32r/LuHf6QCJXi+AEu35koNlNAvLJ2B+KACaNB7N0EeWmqpV/1V2k9p +lDYk+th7LcCuaFNGqKS/PrMnnMqR6VDLCjHhNx4KR79b0Twm/2qp6an3hyNRu8Gn +dwxpf1/BUu3JWf+LqkN4Y3mbOmSUL3MaJNvyQguUzTfS0P0uGuBDHrJCVkMZCzDB +89ag55jCPHyGeHBTd02gHMWzsg3WMBWvCsrzABEBAAG0JXRlcnJhZm9ybSAodGVz +dCkgPHRlc3RAdGVycmFmb3JtLmNvbT6JAU4EEwEIADgWIQSHYyc6Kj9l6HzQsau6 +vFFc9jxV/wUCXJQPywIbAwULCQgHAgYVCgkICwIEFgIDAQIeAQIXgAAKCRC6vFFc +9jxV/815CAClb32OxV7wG01yF97TzlyTl8TnvjMtoG29Mw4nSyg+mjM3b8N7iXm9 +OLX59fbDAWtBSldSZE22RXd3CvlFOG/EnKBXSjBtEqfyxYSnyOPkMPBYWGL/ApkX +SvPYJ4LKdvipYToKFh3y9kk2gk1DcDBDyaaHvR+3rv1u3aoy7/s2EltAfDS3ZQIq +7/cWTLJml/lleeB/Y6rPj8xqeCYhE5ahw9gsV/Mdqatl24V9Tks30iijx0Hhw+Gx +kATUikMGr2GDVqoIRga5kXI7CzYff4rkc0Twn47fMHHHe/KY9M2yVnMHUXmAZwbG +M1cMI/NH1DjevCKdGBLcRJlhuLPKF/anuQENBFyUD8sBCADIpd7r7GuPd6n/Ikxe +u6h7umV6IIPoAm88xCYpTbSZiaK30Svh6Ywra9jfE2KlU9o6Y/art8ip0VJ3m07L +4RSfSpnzqgSwdjSq5hNour2Fo/BzYhK7yaz2AzVSbe33R0+RYhb4b/6N+bKbjwGF +ftCsqVFMH+PyvYkLbvxyQrHlA9woAZaNThI1ztO5rGSnGUR8xt84eup28WIFKg0K +UEGUcTzz+8QGAwAra+0ewPXo/AkO+8BvZjDidP417u6gpBHOJ9qYIcO9FxHeqFyu +YrjlrxowEgXn5wO8xuNz6Vu1vhHGDHGDsRbZF8pv1d5O+0F1G7ttZ2GRRgVBZPwi +kiyRABEBAAGJATYEGAEIACAWIQSHYyc6Kj9l6HzQsau6vFFc9jxV/wUCXJQPywIb +DAAKCRC6vFFc9jxV/9YOCACe8qmOSnKQpQfW+PqYOqo3dt7JyweTs3FkD6NT8Zml +dYy/vkstbTjPpX6aTvUZjkb46BVi7AOneVHpD5GBqvRsZ9iVgDYHaehmLCdKiG5L +3Tp90NN+QY5WDbsGmsyk6+6ZMYejb4qYfweQeduOj27aavCJdLkCYMoRKfcFYI8c +FaNmEfKKy/r1PO20NXEG6t9t05K/frHy6ZG8bCNYdpagfFVot47r9JaQqWlTNtIR +5+zkkSq/eG9BEtRij3a6cTdQbktdBzx2KBeI0PYc1vlZR0LpuFKZqY9vlE6vTGLR +wMfrTEOvx0NxUM3rpaCgEmuWbB1G1Hu371oyr4srrr+N +=28dr +-----END PGP PUBLIC KEY BLOCK----- +`) +} diff --git a/pkg/commands/logging/cloudfiles/cloudfiles_test.go b/pkg/commands/logging/cloudfiles/cloudfiles_test.go new file mode 100644 index 000000000..4df513552 --- /dev/null +++ b/pkg/commands/logging/cloudfiles/cloudfiles_test.go @@ -0,0 +1,378 @@ +package cloudfiles_test + +import ( + "bytes" + "testing" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/logging/cloudfiles" + "github.com/fastly/cli/pkg/config" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" +) + +func TestCreateCloudfilesInput(t *testing.T) { + for _, testcase := range []struct { + name string + cmd *cloudfiles.CreateCommand + want *fastly.CreateCloudfilesInput + wantError string + }{ + { + name: "required values set flag serviceID", + cmd: createCommandRequired(), + want: &fastly.CreateCloudfilesInput{ + ServiceID: "123", + ServiceVersion: 4, + Name: fastly.ToPointer("log"), + User: fastly.ToPointer("user"), + AccessKey: fastly.ToPointer("key"), + BucketName: fastly.ToPointer("bucket"), + }, + }, + { + name: "all values set flag serviceID", + cmd: createCommandAll(), + want: &fastly.CreateCloudfilesInput{ + ServiceID: "123", + ServiceVersion: 4, + Name: fastly.ToPointer("log"), + User: fastly.ToPointer("user"), + AccessKey: fastly.ToPointer("key"), + BucketName: fastly.ToPointer("bucket"), + Path: fastly.ToPointer("/logs"), + Region: fastly.ToPointer("abc"), + Placement: fastly.ToPointer("none"), + Period: fastly.ToPointer(3600), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + FormatVersion: fastly.ToPointer(2), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + MessageType: fastly.ToPointer("classic"), + TimestampFormat: fastly.ToPointer("%Y-%m-%dT%H:%M:%S.000"), + PublicKey: fastly.ToPointer(pgpPublicKey()), + CompressionCodec: fastly.ToPointer("zstd"), + }, + }, + { + name: "error missing serviceID", + cmd: createCommandMissingServiceID(), + want: nil, + wantError: errors.ErrNoServiceID.Error(), + }, + } { + t.Run(testcase.name, func(t *testing.T) { + var bs []byte + out := bytes.NewBuffer(bs) + verboseMode := true + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + AutoCloneFlag: testcase.cmd.AutoClone, + APIClient: testcase.cmd.Globals.APIClient, + Manifest: testcase.cmd.Manifest, + Out: out, + ServiceVersionFlag: testcase.cmd.ServiceVersion, + VerboseMode: verboseMode, + }) + + switch { + case err != nil && testcase.wantError == "": + t.Fatalf("unexpected error getting service details: %v", err) + return + case err != nil && testcase.wantError != "": + testutil.AssertErrorContains(t, err, testcase.wantError) + return + case err == nil && testcase.wantError != "": + t.Fatalf("expected error, have nil (service details: %s, %d)", serviceID, serviceVersion.Number) + case err == nil && testcase.wantError == "": + have, err := testcase.cmd.ConstructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertEqual(t, testcase.want, have) + } + }) + } +} + +func TestUpdateCloudfilesInput(t *testing.T) { + scenarios := []struct { + name string + cmd *cloudfiles.UpdateCommand + api mock.API + want *fastly.UpdateCloudfilesInput + wantError string + }{ + { + name: "no update", + cmd: updateCommandNoUpdate(), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + GetCloudfilesFn: getCloudfilesOK, + }, + want: &fastly.UpdateCloudfilesInput{ + ServiceID: "123", + ServiceVersion: 4, + Name: "log", + }, + }, + { + name: "all values set flag serviceID", + cmd: updateCommandAll(), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + GetCloudfilesFn: getCloudfilesOK, + }, + want: &fastly.UpdateCloudfilesInput{ + ServiceID: "123", + ServiceVersion: 4, + Name: "log", + NewName: fastly.ToPointer("new1"), + AccessKey: fastly.ToPointer("new2"), + BucketName: fastly.ToPointer("new3"), + Path: fastly.ToPointer("new4"), + Region: fastly.ToPointer("new5"), + Placement: fastly.ToPointer("new6"), + Period: fastly.ToPointer(3601), + GzipLevel: fastly.ToPointer(0), + Format: fastly.ToPointer("new7"), + FormatVersion: fastly.ToPointer(3), + ResponseCondition: fastly.ToPointer("new8"), + MessageType: fastly.ToPointer("new9"), + TimestampFormat: fastly.ToPointer("new10"), + PublicKey: fastly.ToPointer("new11"), + User: fastly.ToPointer("new12"), + CompressionCodec: fastly.ToPointer("new13"), + }, + }, + { + name: "error missing serviceID", + cmd: updateCommandMissingServiceID(), + want: nil, + wantError: errors.ErrNoServiceID.Error(), + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(testcase.name, func(t *testing.T) { + testcase.cmd.Globals.APIClient = testcase.api + + var bs []byte + out := bytes.NewBuffer(bs) + verboseMode := true + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + AutoCloneFlag: testcase.cmd.AutoClone, + APIClient: testcase.api, + Manifest: testcase.cmd.Manifest, + Out: out, + ServiceVersionFlag: testcase.cmd.ServiceVersion, + VerboseMode: verboseMode, + }) + + switch { + case err != nil && testcase.wantError == "": + t.Fatalf("unexpected error getting service details: %v", err) + return + case err != nil && testcase.wantError != "": + testutil.AssertErrorContains(t, err, testcase.wantError) + return + case err == nil && testcase.wantError != "": + t.Fatalf("expected error, have nil (service details: %s, %d)", serviceID, serviceVersion.Number) + case err == nil && testcase.wantError == "": + have, err := testcase.cmd.ConstructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertEqual(t, testcase.want, have) + } + }) + } +} + +func createCommandRequired() *cloudfiles.CreateCommand { + var b bytes.Buffer + + g := global.Data{ + Config: config.File{}, + Env: config.Environment{}, + Output: &b, + } + g.APIClient, _ = mock.APIClient(mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + })("token", "endpoint", false) + + return &cloudfiles.CreateCommand{ + Base: argparser.Base{ + Globals: &g, + }, + Manifest: manifest.Data{ + Flag: manifest.Flag{ + ServiceID: "123", + }, + }, + ServiceVersion: argparser.OptionalServiceVersion{ + OptionalString: argparser.OptionalString{Value: "1"}, + }, + AutoClone: argparser.OptionalAutoClone{ + OptionalBool: argparser.OptionalBool{ + Optional: argparser.Optional{ + WasSet: true, + }, + Value: true, + }, + }, + EndpointName: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "log"}, + User: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "user"}, + AccessKey: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "key"}, + BucketName: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "bucket"}, + } +} + +func createCommandAll() *cloudfiles.CreateCommand { + var b bytes.Buffer + + g := global.Data{ + Config: config.File{}, + Env: config.Environment{}, + Output: &b, + } + g.APIClient, _ = mock.APIClient(mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + })("token", "endpoint", false) + + return &cloudfiles.CreateCommand{ + Base: argparser.Base{ + Globals: &g, + }, + Manifest: manifest.Data{ + Flag: manifest.Flag{ + ServiceID: "123", + }, + }, + ServiceVersion: argparser.OptionalServiceVersion{ + OptionalString: argparser.OptionalString{Value: "1"}, + }, + AutoClone: argparser.OptionalAutoClone{ + OptionalBool: argparser.OptionalBool{ + Optional: argparser.Optional{ + WasSet: true, + }, + Value: true, + }, + }, + EndpointName: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "log"}, + User: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "user"}, + AccessKey: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "key"}, + BucketName: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "bucket"}, + Path: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "/logs"}, + Region: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "abc"}, + Placement: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "none"}, + Period: argparser.OptionalInt{Optional: argparser.Optional{WasSet: true}, Value: 3600}, + Format: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: `%h %l %u %t "%r" %>s %b`}, + FormatVersion: argparser.OptionalInt{Optional: argparser.Optional{WasSet: true}, Value: 2}, + ResponseCondition: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "Prevent default logging"}, + MessageType: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "classic"}, + TimestampFormat: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "%Y-%m-%dT%H:%M:%S.000"}, + PublicKey: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: pgpPublicKey()}, + CompressionCodec: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "zstd"}, + } +} + +func createCommandMissingServiceID() *cloudfiles.CreateCommand { + res := createCommandAll() + res.Manifest = manifest.Data{} + return res +} + +func updateCommandNoUpdate() *cloudfiles.UpdateCommand { + var b bytes.Buffer + + g := global.Data{ + Config: config.File{}, + Env: config.Environment{}, + Output: &b, + } + + return &cloudfiles.UpdateCommand{ + Base: argparser.Base{ + Globals: &g, + }, + Manifest: manifest.Data{ + Flag: manifest.Flag{ + ServiceID: "123", + }, + }, + ServiceVersion: argparser.OptionalServiceVersion{ + OptionalString: argparser.OptionalString{Value: "1"}, + }, + AutoClone: argparser.OptionalAutoClone{ + OptionalBool: argparser.OptionalBool{ + Optional: argparser.Optional{ + WasSet: true, + }, + Value: true, + }, + }, + EndpointName: "log", + } +} + +func updateCommandAll() *cloudfiles.UpdateCommand { + var b bytes.Buffer + + g := global.Data{ + Config: config.File{}, + Env: config.Environment{}, + Output: &b, + } + + return &cloudfiles.UpdateCommand{ + Base: argparser.Base{ + Globals: &g, + }, + Manifest: manifest.Data{ + Flag: manifest.Flag{ + ServiceID: "123", + }, + }, + ServiceVersion: argparser.OptionalServiceVersion{ + OptionalString: argparser.OptionalString{Value: "1"}, + }, + AutoClone: argparser.OptionalAutoClone{ + OptionalBool: argparser.OptionalBool{ + Optional: argparser.Optional{ + WasSet: true, + }, + Value: true, + }, + }, + EndpointName: "log", + NewName: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new1"}, + AccessKey: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new2"}, + BucketName: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new3"}, + Path: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new4"}, + Region: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new5"}, + Placement: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new6"}, + Period: argparser.OptionalInt{Optional: argparser.Optional{WasSet: true}, Value: 3601}, + GzipLevel: argparser.OptionalInt{Optional: argparser.Optional{WasSet: true}, Value: 0}, + Format: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new7"}, + FormatVersion: argparser.OptionalInt{Optional: argparser.Optional{WasSet: true}, Value: 3}, + ResponseCondition: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new8"}, + MessageType: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new9"}, + TimestampFormat: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new10"}, + PublicKey: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new11"}, + User: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new12"}, + CompressionCodec: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new13"}, + } +} + +func updateCommandMissingServiceID() *cloudfiles.UpdateCommand { + res := updateCommandAll() + res.Manifest = manifest.Data{} + return res +} diff --git a/pkg/commands/logging/cloudfiles/create.go b/pkg/commands/logging/cloudfiles/create.go new file mode 100644 index 000000000..34f9a906a --- /dev/null +++ b/pkg/commands/logging/cloudfiles/create.go @@ -0,0 +1,213 @@ +package cloudfiles + +import ( + "fmt" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/logging/common" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/text" +) + +// CreateCommand calls the Fastly API to create a Cloudfiles logging endpoint. +type CreateCommand struct { + argparser.Base + Manifest manifest.Data + + // Required. + ServiceName argparser.OptionalServiceNameID + ServiceVersion argparser.OptionalServiceVersion + + // Optional. + AccessKey argparser.OptionalString + AutoClone argparser.OptionalAutoClone + BucketName argparser.OptionalString + CompressionCodec argparser.OptionalString + EndpointName argparser.OptionalString // Can't shadow argparser.Base method Name(). + Format argparser.OptionalString + FormatVersion argparser.OptionalInt + GzipLevel argparser.OptionalInt + MessageType argparser.OptionalString + Path argparser.OptionalString + Period argparser.OptionalInt + Placement argparser.OptionalString + PublicKey argparser.OptionalString + Region argparser.OptionalString + ResponseCondition argparser.OptionalString + TimestampFormat argparser.OptionalString + Token argparser.OptionalString + User argparser.OptionalString +} + +// NewCreateCommand returns a usable command registered under the parent. +func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { + c := CreateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("create", "Create a Cloudfiles logging endpoint on a Fastly service version").Alias("add") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.ServiceVersion.Value, + Required: true, + }) + + // Optional. + c.CmdClause.Flag("access-key", "Your Cloudfile account access key").Action(c.AccessKey.Set).StringVar(&c.AccessKey.Value) + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.AutoClone.Set, + Dst: &c.AutoClone.Value, + }) + c.CmdClause.Flag("bucket", "The name of your Cloudfiles container").Action(c.BucketName.Set).StringVar(&c.BucketName.Value) + common.CompressionCodec(c.CmdClause, &c.CompressionCodec) + common.Format(c.CmdClause, &c.Format) + common.FormatVersion(c.CmdClause, &c.FormatVersion) + common.GzipLevel(c.CmdClause, &c.GzipLevel) + common.MessageType(c.CmdClause, &c.MessageType) + c.CmdClause.Flag("name", "The name of the Cloudfiles logging object. Used as a primary key for API access").Short('n').Action(c.EndpointName.Set).StringVar(&c.EndpointName.Value) + common.Path(c.CmdClause, &c.Path) + common.Period(c.CmdClause, &c.Period) + common.Placement(c.CmdClause, &c.Placement) + common.PublicKey(c.CmdClause, &c.PublicKey) + c.CmdClause.Flag("region", "The region to stream logs to. One of: DFW-Dallas, ORD-Chicago, IAD-Northern Virginia, LON-London, SYD-Sydney, HKG-Hong Kong").Action(c.Region.Set).StringVar(&c.Region.Value) + common.ResponseCondition(c.CmdClause, &c.ResponseCondition) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.ServiceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.ServiceName.Value, + }) + common.TimestampFormat(c.CmdClause, &c.TimestampFormat) + c.CmdClause.Flag("user", "The username for your Cloudfile account").Action(c.User.Set).StringVar(&c.User.Value) + return &c +} + +// ConstructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *CreateCommand) ConstructInput(serviceID string, serviceVersion int) (*fastly.CreateCloudfilesInput, error) { + var input fastly.CreateCloudfilesInput + + input.ServiceID = serviceID + input.ServiceVersion = serviceVersion + if c.EndpointName.WasSet { + input.Name = &c.EndpointName.Value + } + if c.User.WasSet { + input.User = &c.User.Value + } + if c.AccessKey.WasSet { + input.AccessKey = &c.AccessKey.Value + } + if c.BucketName.WasSet { + input.BucketName = &c.BucketName.Value + } + + // The following blocks enforces the mutual exclusivity of the + // CompressionCodec and GzipLevel flags. + if c.CompressionCodec.WasSet && c.GzipLevel.WasSet { + return nil, fmt.Errorf("error parsing arguments: the --compression-codec flag is mutually exclusive with the --gzip-level flag") + } + + if c.Path.WasSet { + input.Path = &c.Path.Value + } + if c.Region.WasSet { + input.Region = &c.Region.Value + } + if c.Placement.WasSet { + input.Placement = &c.Placement.Value + } + if c.Period.WasSet { + input.Period = &c.Period.Value + } + if c.GzipLevel.WasSet { + input.GzipLevel = &c.GzipLevel.Value + } + if c.Format.WasSet { + input.Format = fastly.ToPointer(argparser.Content(c.Format.Value)) + } + if c.FormatVersion.WasSet { + input.FormatVersion = &c.FormatVersion.Value + } + if c.ResponseCondition.WasSet { + input.ResponseCondition = &c.ResponseCondition.Value + } + if c.MessageType.WasSet { + input.MessageType = &c.MessageType.Value + } + if c.TimestampFormat.WasSet { + input.TimestampFormat = &c.TimestampFormat.Value + } + if c.PublicKey.WasSet { + input.PublicKey = &c.PublicKey.Value + } + if c.CompressionCodec.WasSet { + input.CompressionCodec = &c.CompressionCodec.Value + } + + return &input, nil +} + +// Exec invokes the application logic for the command. +func (c *CreateCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.AutoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.ServiceName, + ServiceVersionFlag: c.ServiceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fsterr.ServiceVersion(serviceVersion), + }) + return err + } + + input, err := c.ConstructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fastly.ToValue(serviceVersion.Number), + }) + return err + } + + d, err := c.Globals.APIClient.CreateCloudfiles(input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fastly.ToValue(serviceVersion.Number), + }) + return err + } + + text.Success(out, + "Created Cloudfiles logging endpoint %s (service %s version %d)", + fastly.ToValue(d.Name), + fastly.ToValue(d.ServiceID), + fastly.ToValue(d.ServiceVersion), + ) + return nil +} diff --git a/pkg/commands/logging/cloudfiles/delete.go b/pkg/commands/logging/cloudfiles/delete.go new file mode 100644 index 000000000..03706879b --- /dev/null +++ b/pkg/commands/logging/cloudfiles/delete.go @@ -0,0 +1,97 @@ +package cloudfiles + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// DeleteCommand calls the Fastly API to delete a Cloudfiles logging endpoint. +type DeleteCommand struct { + argparser.Base + Input fastly.DeleteCloudfilesInput + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion + autoClone argparser.OptionalAutoClone +} + +// NewDeleteCommand returns a usable command registered under the parent. +func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { + c := DeleteCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("delete", "Delete a Cloudfiles logging endpoint on a Fastly service version").Alias("remove") + + // Required. + c.CmdClause.Flag("name", "The name of the Cloudfiles logging object").Short('n').Required().StringVar(&c.Input.Name) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.autoClone.Set, + Dst: &c.autoClone.Value, + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + return &c +} + +// Exec invokes the application logic for the command. +func (c *DeleteCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.autoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + c.Input.ServiceID = serviceID + c.Input.ServiceVersion = fastly.ToValue(serviceVersion.Number) + + if err := c.Globals.APIClient.DeleteCloudfiles(&c.Input); err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fastly.ToValue(serviceVersion.Number), + }) + return err + } + + text.Success(out, "Deleted Cloudfiles logging endpoint %s (service %s version %d)", c.Input.Name, c.Input.ServiceID, c.Input.ServiceVersion) + return nil +} diff --git a/pkg/commands/logging/cloudfiles/describe.go b/pkg/commands/logging/cloudfiles/describe.go new file mode 100644 index 000000000..213511bb6 --- /dev/null +++ b/pkg/commands/logging/cloudfiles/describe.go @@ -0,0 +1,121 @@ +package cloudfiles + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// DescribeCommand calls the Fastly API to describe a Cloudfiles logging endpoint. +type DescribeCommand struct { + argparser.Base + argparser.JSONOutput + + Input fastly.GetCloudfilesInput + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion +} + +// NewDescribeCommand returns a usable command registered under the parent. +func NewDescribeCommand(parent argparser.Registerer, g *global.Data) *DescribeCommand { + c := DescribeCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("describe", "Show detailed information about a Cloudfiles logging endpoint on a Fastly service version").Alias("get") + + // Required. + c.CmdClause.Flag("name", "The name of the Cloudfiles logging object").Short('n').Required().StringVar(&c.Input.Name) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + return &c +} + +// Exec invokes the application logic for the command. +func (c *DescribeCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fsterr.ServiceVersion(serviceVersion), + }) + return err + } + + c.Input.ServiceID = serviceID + c.Input.ServiceVersion = fastly.ToValue(serviceVersion.Number) + + o, err := c.Globals.APIClient.GetCloudfiles(&c.Input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fastly.ToValue(serviceVersion.Number), + }) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + lines := text.Lines{ + "Access key": fastly.ToValue(o.AccessKey), + "Bucket": fastly.ToValue(o.BucketName), + "Format version": fastly.ToValue(o.FormatVersion), + "Format": fastly.ToValue(o.Format), + "GZip level": fastly.ToValue(o.GzipLevel), + "Message type": fastly.ToValue(o.MessageType), + "Name": fastly.ToValue(o.Name), + "Path": fastly.ToValue(o.Path), + "Period": fastly.ToValue(o.Period), + "Placement": fastly.ToValue(o.Placement), + "Public key": fastly.ToValue(o.PublicKey), + "Region": fastly.ToValue(o.Region), + "Response condition": fastly.ToValue(o.ResponseCondition), + "Timestamp format": fastly.ToValue(o.TimestampFormat), + "User": fastly.ToValue(o.User), + "Version": fastly.ToValue(o.ServiceVersion), + } + if !c.Globals.Verbose() { + lines["Service ID"] = fastly.ToValue(o.ServiceID) + } + text.PrintLines(out, lines) + + return nil +} diff --git a/pkg/logging/cloudfiles/doc.go b/pkg/commands/logging/cloudfiles/doc.go similarity index 100% rename from pkg/logging/cloudfiles/doc.go rename to pkg/commands/logging/cloudfiles/doc.go diff --git a/pkg/commands/logging/cloudfiles/list.go b/pkg/commands/logging/cloudfiles/list.go new file mode 100644 index 000000000..e67ab92bd --- /dev/null +++ b/pkg/commands/logging/cloudfiles/list.go @@ -0,0 +1,135 @@ +package cloudfiles + +import ( + "fmt" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// ListCommand calls the Fastly API to list Cloudfiles logging endpoints. +type ListCommand struct { + argparser.Base + argparser.JSONOutput + + Input fastly.ListCloudfilesInput + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion +} + +// NewListCommand returns a usable command registered under the parent. +func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { + c := ListCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("list", "List Cloudfiles endpoints on a Fastly service version") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + return &c +} + +// Exec invokes the application logic for the command. +func (c *ListCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fsterr.ServiceVersion(serviceVersion), + }) + return err + } + + c.Input.ServiceID = serviceID + c.Input.ServiceVersion = fastly.ToValue(serviceVersion.Number) + + o, err := c.Globals.APIClient.ListCloudfiles(&c.Input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fastly.ToValue(serviceVersion.Number), + }) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + if !c.Globals.Verbose() { + tw := text.NewTable(out) + tw.AddHeader("SERVICE", "VERSION", "NAME") + for _, cloudfile := range o { + tw.AddLine( + fastly.ToValue(cloudfile.ServiceID), + fastly.ToValue(cloudfile.ServiceVersion), + fastly.ToValue(cloudfile.Name), + ) + } + tw.Print() + return nil + } + + fmt.Fprintf(out, "Version: %d\n", c.Input.ServiceVersion) + for i, cloudfile := range o { + fmt.Fprintf(out, "\tCloudfiles %d/%d\n", i+1, len(o)) + fmt.Fprintf(out, "\t\tService ID: %s\n", fastly.ToValue(cloudfile.ServiceID)) + fmt.Fprintf(out, "\t\tVersion: %d\n", fastly.ToValue(cloudfile.ServiceVersion)) + fmt.Fprintf(out, "\t\tName: %s\n", fastly.ToValue(cloudfile.Name)) + fmt.Fprintf(out, "\t\tUser: %s\n", fastly.ToValue(cloudfile.User)) + fmt.Fprintf(out, "\t\tAccess key: %s\n", fastly.ToValue(cloudfile.AccessKey)) + fmt.Fprintf(out, "\t\tBucket: %s\n", fastly.ToValue(cloudfile.BucketName)) + fmt.Fprintf(out, "\t\tPath: %s\n", fastly.ToValue(cloudfile.Path)) + fmt.Fprintf(out, "\t\tRegion: %s\n", fastly.ToValue(cloudfile.Region)) + fmt.Fprintf(out, "\t\tPlacement: %s\n", fastly.ToValue(cloudfile.Placement)) + fmt.Fprintf(out, "\t\tPeriod: %d\n", fastly.ToValue(cloudfile.Period)) + fmt.Fprintf(out, "\t\tGZip level: %d\n", fastly.ToValue(cloudfile.GzipLevel)) + fmt.Fprintf(out, "\t\tFormat: %s\n", fastly.ToValue(cloudfile.Format)) + fmt.Fprintf(out, "\t\tFormat version: %d\n", fastly.ToValue(cloudfile.FormatVersion)) + fmt.Fprintf(out, "\t\tResponse condition: %s\n", fastly.ToValue(cloudfile.ResponseCondition)) + fmt.Fprintf(out, "\t\tMessage type: %s\n", fastly.ToValue(cloudfile.MessageType)) + fmt.Fprintf(out, "\t\tTimestamp format: %s\n", fastly.ToValue(cloudfile.TimestampFormat)) + fmt.Fprintf(out, "\t\tPublic key: %s\n", fastly.ToValue(cloudfile.PublicKey)) + } + fmt.Fprintln(out) + + return nil +} diff --git a/pkg/commands/logging/cloudfiles/root.go b/pkg/commands/logging/cloudfiles/root.go new file mode 100644 index 000000000..874ab2081 --- /dev/null +++ b/pkg/commands/logging/cloudfiles/root.go @@ -0,0 +1,31 @@ +package cloudfiles + +import ( + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" +) + +// RootCommand is the parent command for all subcommands in this package. +// It should be installed under the primary root command. +type RootCommand struct { + argparser.Base + // no flags +} + +// CommandName is the string to be used to invoke this command. +const CommandName = "cloudfiles" + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent argparser.Registerer, g *global.Data) *RootCommand { + var c RootCommand + c.Globals = g + c.CmdClause = parent.Command(CommandName, "Manipulate Fastly service version Cloudfiles logging endpoints") + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { + panic("unreachable") +} diff --git a/pkg/commands/logging/cloudfiles/update.go b/pkg/commands/logging/cloudfiles/update.go new file mode 100644 index 000000000..5b1637055 --- /dev/null +++ b/pkg/commands/logging/cloudfiles/update.go @@ -0,0 +1,224 @@ +package cloudfiles + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/logging/common" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/text" +) + +// UpdateCommand calls the Fastly API to update a Cloudfiles logging endpoint. +type UpdateCommand struct { + argparser.Base + Manifest manifest.Data + + // Required. + EndpointName string // Can't shadow argparser.Base method Name(). + ServiceName argparser.OptionalServiceNameID + ServiceVersion argparser.OptionalServiceVersion + + // Optional. + AutoClone argparser.OptionalAutoClone + NewName argparser.OptionalString + User argparser.OptionalString + AccessKey argparser.OptionalString + BucketName argparser.OptionalString + Path argparser.OptionalString + Region argparser.OptionalString + Placement argparser.OptionalString + Period argparser.OptionalInt + GzipLevel argparser.OptionalInt + Format argparser.OptionalString + FormatVersion argparser.OptionalInt + ResponseCondition argparser.OptionalString + MessageType argparser.OptionalString + TimestampFormat argparser.OptionalString + PublicKey argparser.OptionalString + CompressionCodec argparser.OptionalString +} + +// NewUpdateCommand returns a usable command registered under the parent. +func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { + c := UpdateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("update", "Update a Cloudfiles logging endpoint on a Fastly service version") + + // Required. + c.CmdClause.Flag("name", "The name of the Cloudfiles logging object").Short('n').Required().StringVar(&c.EndpointName) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.ServiceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.AutoClone.Set, + Dst: &c.AutoClone.Value, + }) + c.CmdClause.Flag("access-key", "Your Cloudfile account access key").Action(c.AccessKey.Set).StringVar(&c.AccessKey.Value) + c.CmdClause.Flag("bucket", "The name of your Cloudfiles container").Action(c.BucketName.Set).StringVar(&c.BucketName.Value) + common.CompressionCodec(c.CmdClause, &c.CompressionCodec) + common.Format(c.CmdClause, &c.Format) + common.FormatVersion(c.CmdClause, &c.FormatVersion) + common.GzipLevel(c.CmdClause, &c.GzipLevel) + common.MessageType(c.CmdClause, &c.MessageType) + c.CmdClause.Flag("new-name", "New name of the Cloudfiles logging object").Action(c.NewName.Set).StringVar(&c.NewName.Value) + common.Path(c.CmdClause, &c.Path) + common.Period(c.CmdClause, &c.Period) + common.Placement(c.CmdClause, &c.Placement) + common.PublicKey(c.CmdClause, &c.PublicKey) + c.CmdClause.Flag("region", "The region to stream logs to. One of: DFW-Dallas, ORD-Chicago, IAD-Northern Virginia, LON-London, SYD-Sydney, HKG-Hong Kong").Action(c.Region.Set).StringVar(&c.Region.Value) + c.CmdClause.Flag("user", "The username for your Cloudfile account").Action(c.User.Set).StringVar(&c.User.Value) + common.ResponseCondition(c.CmdClause, &c.ResponseCondition) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.ServiceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.ServiceName.Value, + }) + common.TimestampFormat(c.CmdClause, &c.TimestampFormat) + return &c +} + +// ConstructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *UpdateCommand) ConstructInput(serviceID string, serviceVersion int) (*fastly.UpdateCloudfilesInput, error) { + input := fastly.UpdateCloudfilesInput{ + ServiceID: serviceID, + ServiceVersion: serviceVersion, + Name: c.EndpointName, + } + + // Set new values if set by user. + if c.NewName.WasSet { + input.NewName = &c.NewName.Value + } + + if c.User.WasSet { + input.User = &c.User.Value + } + + if c.AccessKey.WasSet { + input.AccessKey = &c.AccessKey.Value + } + + if c.BucketName.WasSet { + input.BucketName = &c.BucketName.Value + } + + if c.Path.WasSet { + input.Path = &c.Path.Value + } + + if c.Region.WasSet { + input.Region = &c.Region.Value + } + + if c.Placement.WasSet { + input.Placement = &c.Placement.Value + } + + if c.Period.WasSet { + input.Period = &c.Period.Value + } + + if c.GzipLevel.WasSet { + input.GzipLevel = &c.GzipLevel.Value + } + + if c.Format.WasSet { + input.Format = fastly.ToPointer(argparser.Content(c.Format.Value)) + } + + if c.FormatVersion.WasSet { + input.FormatVersion = &c.FormatVersion.Value + } + + if c.ResponseCondition.WasSet { + input.ResponseCondition = &c.ResponseCondition.Value + } + + if c.MessageType.WasSet { + input.MessageType = &c.MessageType.Value + } + + if c.TimestampFormat.WasSet { + input.TimestampFormat = &c.TimestampFormat.Value + } + + if c.PublicKey.WasSet { + input.PublicKey = &c.PublicKey.Value + } + + if c.CompressionCodec.WasSet { + input.CompressionCodec = &c.CompressionCodec.Value + } + + return &input, nil +} + +// Exec invokes the application logic for the command. +func (c *UpdateCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.AutoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.ServiceName, + ServiceVersionFlag: c.ServiceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fsterr.ServiceVersion(serviceVersion), + }) + return err + } + + input, err := c.ConstructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fastly.ToValue(serviceVersion.Number), + }) + return err + } + + cloudfiles, err := c.Globals.APIClient.UpdateCloudfiles(input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fastly.ToValue(serviceVersion.Number), + }) + return err + } + + text.Success(out, + "Updated Cloudfiles logging endpoint %s (service %s version %d)", + fastly.ToValue(cloudfiles.Name), + fastly.ToValue(cloudfiles.ServiceID), + fastly.ToValue(cloudfiles.ServiceVersion), + ) + return nil +} diff --git a/pkg/commands/logging/common/doc.go b/pkg/commands/logging/common/doc.go new file mode 100644 index 000000000..7f4de3836 --- /dev/null +++ b/pkg/commands/logging/common/doc.go @@ -0,0 +1,2 @@ +// Package common contains common flags used in the logging commands +package common diff --git a/pkg/commands/logging/common/flags.go b/pkg/commands/logging/common/flags.go new file mode 100644 index 000000000..26c07c2fb --- /dev/null +++ b/pkg/commands/logging/common/flags.go @@ -0,0 +1,87 @@ +package common + +import ( + "github.com/fastly/kingpin" + + "github.com/fastly/cli/pkg/argparser" +) + +// AccountName defines the account-name flag. +func AccountName(command *kingpin.CmdClause, c *argparser.OptionalString) { + command.Flag("account-name", "The google account name used to obtain temporary credentials (default none)").Action(c.Set).StringVar(&c.Value) +} + +// Format defines the format flag. +func Format(command *kingpin.CmdClause, c *argparser.OptionalString) { + command.Flag("format", "Apache style log formatting. Your log must produce valid JSON. Can be a string or a file path to a file containing formatting").Action(c.Set).StringVar(&c.Value) +} + +// GzipLevel defines the gzip flag. +func GzipLevel(command *kingpin.CmdClause, c *argparser.OptionalInt) { + command.Flag("gzip-level", "What level of GZIP encoding to have when dumping logs (default 0, no compression)").Action(c.Set).IntVar(&c.Value) +} + +// Path defines the path flag. +func Path(command *kingpin.CmdClause, c *argparser.OptionalString) { + command.Flag("path", "The path to upload logs to").Action(c.Set).StringVar(&c.Value) +} + +// MessageType defines the path flag. +func MessageType(command *kingpin.CmdClause, c *argparser.OptionalString) { + command.Flag("message-type", "How the message should be formatted. One of: classic (default), loggly, logplex or blank").Action(c.Set).StringVar(&c.Value) +} + +// Period defines the period flag. +func Period(command *kingpin.CmdClause, c *argparser.OptionalInt) { + command.Flag("period", "How frequently log files are finalized so they can be available for reading (in seconds, default 3600)").Action(c.Set).IntVar(&c.Value) +} + +// FormatVersion defines the format-version flag. +func FormatVersion(command *kingpin.CmdClause, c *argparser.OptionalInt) { + command.Flag("format-version", "The version of the custom logging format used for the configured endpoint. Can be either 2 (the default, version 2 log format) or 1 (the version 1 log format). The logging call gets placed by default in vcl_log if format_version is set to 2 and in vcl_deliver if format_version is set to 1").Action(c.Set).IntVar(&c.Value) +} + +// CompressionCodec defines the compression-codec flag. +func CompressionCodec(command *kingpin.CmdClause, c *argparser.OptionalString) { + command.Flag("compression-codec", `The codec used for compression of your logs. Valid values are zstd, snappy, and gzip. If the specified codec is "gzip", gzip_level will default to 3. To specify a different level, leave compression_codec blank and explicitly set the level using gzip_level. Specifying both compression_codec and gzip_level in the same API request will result in an error.`).Action(c.Set).StringVar(&c.Value) +} + +// Placement defines the placement flag. +func Placement(command *kingpin.CmdClause, c *argparser.OptionalString) { + command.Flag("placement", "Where in the generated VCL the logging call should be placed, overriding any format_version default. Can be none or waf_debug. This field is not required and has no default value").Action(c.Set).StringVar(&c.Value) +} + +// ResponseCondition defines the response-condition flag. +func ResponseCondition(command *kingpin.CmdClause, c *argparser.OptionalString) { + command.Flag("response-condition", "The name of an existing condition in the configured endpoint, or leave blank to always execute").Action(c.Set).StringVar(&c.Value) +} + +// TimestampFormat defines the timestamp-format flag. +func TimestampFormat(command *kingpin.CmdClause, c *argparser.OptionalString) { + command.Flag("timestamp-format", `strftime specified timestamp formatting (default "%Y-%m-%dT%H:%M:%S.000")`).Action(c.Set).StringVar(&c.Value) +} + +// PublicKey defines the public-key flag. +func PublicKey(command *kingpin.CmdClause, c *argparser.OptionalString) { + command.Flag("public-key", "A PGP public key that Fastly will use to encrypt your log files before writing them to disk").Action(c.Set).StringVar(&c.Value) +} + +// TLSCACert defines the tls-ca-cert flag. +func TLSCACert(command *kingpin.CmdClause, c *argparser.OptionalString) { + command.Flag("tls-ca-cert", "A secure certificate to authenticate the server with. Must be in PEM format").Action(c.Set).StringVar(&c.Value) +} + +// TLSHostname defines the tls-hostname flag. +func TLSHostname(command *kingpin.CmdClause, c *argparser.OptionalString) { + command.Flag("tls-hostname", "Used during the TLS handshake to validate the certificate").Action(c.Set).StringVar(&c.Value) +} + +// TLSClientCert defines the tls-client-cert flag. +func TLSClientCert(command *kingpin.CmdClause, c *argparser.OptionalString) { + command.Flag("tls-client-cert", "The client certificate used to make authenticated requests. Must be in PEM format").Action(c.Set).StringVar(&c.Value) +} + +// TLSClientKey defines the tls-client-key flag. +func TLSClientKey(command *kingpin.CmdClause, c *argparser.OptionalString) { + command.Flag("tls-client-key", "The client private key used to make authenticated requests. Must be in PEM format").Action(c.Set).StringVar(&c.Value) +} diff --git a/pkg/commands/logging/datadog/create.go b/pkg/commands/logging/datadog/create.go new file mode 100644 index 000000000..49766fef8 --- /dev/null +++ b/pkg/commands/logging/datadog/create.go @@ -0,0 +1,158 @@ +package datadog + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/logging/common" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/text" +) + +// CreateCommand calls the Fastly API to create a Datadog logging endpoint. +type CreateCommand struct { + argparser.Base + Manifest manifest.Data + + // Required. + ServiceName argparser.OptionalServiceNameID + ServiceVersion argparser.OptionalServiceVersion + + // Optional. + AutoClone argparser.OptionalAutoClone + EndpointName argparser.OptionalString // Can't shadow argparser.Base method Name(). + Format argparser.OptionalString + FormatVersion argparser.OptionalInt + Placement argparser.OptionalString + Region argparser.OptionalString + ResponseCondition argparser.OptionalString + Token argparser.OptionalString +} + +// NewCreateCommand returns a usable command registered under the parent. +func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { + c := CreateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("create", "Create a Datadog logging endpoint on a Fastly service version").Alias("add") + + // Required. + c.CmdClause.Flag("name", "The name of the Datadog logging object. Used as a primary key for API access").Short('n').Action(c.EndpointName.Set).StringVar(&c.EndpointName.Value) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.ServiceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.AutoClone.Set, + Dst: &c.AutoClone.Value, + }) + c.CmdClause.Flag("auth-token", "The API key from your Datadog account").Action(c.Token.Set).StringVar(&c.Token.Value) + common.Format(c.CmdClause, &c.Format) + common.FormatVersion(c.CmdClause, &c.FormatVersion) + common.Placement(c.CmdClause, &c.Placement) + c.CmdClause.Flag("region", "The region that log data will be sent to. One of US, US3, US5, or EU. Defaults to US if undefined").Action(c.Region.Set).StringVar(&c.Region.Value) + common.ResponseCondition(c.CmdClause, &c.ResponseCondition) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.ServiceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.ServiceName.Value, + }) + return &c +} + +// ConstructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *CreateCommand) ConstructInput(serviceID string, serviceVersion int) (*fastly.CreateDatadogInput, error) { + var input fastly.CreateDatadogInput + + input.ServiceID = serviceID + input.ServiceVersion = serviceVersion + if c.EndpointName.WasSet { + input.Name = &c.EndpointName.Value + } + if c.Token.WasSet { + input.Token = &c.Token.Value + } + + if c.Region.WasSet { + input.Region = &c.Region.Value + } + + if c.Format.WasSet { + input.Format = fastly.ToPointer(argparser.Content(c.Format.Value)) + } + + if c.FormatVersion.WasSet { + input.FormatVersion = &c.FormatVersion.Value + } + + if c.ResponseCondition.WasSet { + input.ResponseCondition = &c.ResponseCondition.Value + } + + if c.Placement.WasSet { + input.Placement = &c.Placement.Value + } + + return &input, nil +} + +// Exec invokes the application logic for the command. +func (c *CreateCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.AutoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.ServiceName, + ServiceVersionFlag: c.ServiceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + input, err := c.ConstructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + d, err := c.Globals.APIClient.CreateDatadog(input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + text.Success(out, + "Created Datadog logging endpoint %s (service %s version %d)", + fastly.ToValue(d.Name), + fastly.ToValue(d.ServiceID), + fastly.ToValue(d.ServiceVersion), + ) + return nil +} diff --git a/pkg/commands/logging/datadog/datadog_integration_test.go b/pkg/commands/logging/datadog/datadog_integration_test.go new file mode 100644 index 000000000..e2a49f3e3 --- /dev/null +++ b/pkg/commands/logging/datadog/datadog_integration_test.go @@ -0,0 +1,411 @@ +package datadog_test + +import ( + "bytes" + "errors" + "io" + "strings" + "testing" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/app" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" +) + +func TestDatadogCreate(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging datadog create --service-id 123 --version 1 --name log --auth-token abc --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + CreateDatadogFn: createDatadogOK, + }, + wantOutput: "Created Datadog logging endpoint log (service 123 version 4)", + }, + { + args: args("logging datadog create --service-id 123 --version 1 --name log --auth-token abc --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + CreateDatadogFn: createDatadogError, + }, + wantError: errTest.Error(), + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertStringContains(t, stdout.String(), testcase.wantOutput) + }) + } +} + +func TestDatadogList(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging datadog list --service-id 123 --version 1"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListDatadogFn: listDatadogsOK, + }, + wantOutput: listDatadogsShortOutput, + }, + { + args: args("logging datadog list --service-id 123 --version 1 --verbose"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListDatadogFn: listDatadogsOK, + }, + wantOutput: listDatadogsVerboseOutput, + }, + { + args: args("logging datadog list --service-id 123 --version 1 -v"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListDatadogFn: listDatadogsOK, + }, + wantOutput: listDatadogsVerboseOutput, + }, + { + args: args("logging datadog --verbose list --service-id 123 --version 1"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListDatadogFn: listDatadogsOK, + }, + wantOutput: listDatadogsVerboseOutput, + }, + { + args: args("logging -v datadog list --service-id 123 --version 1"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListDatadogFn: listDatadogsOK, + }, + wantOutput: listDatadogsVerboseOutput, + }, + { + args: args("logging datadog list --service-id 123 --version 1"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListDatadogFn: listDatadogsError, + }, + wantError: errTest.Error(), + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertString(t, testcase.wantOutput, stdout.String()) + }) + } +} + +func TestDatadogDescribe(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging datadog describe --service-id 123 --version 1"), + wantError: "error parsing arguments: required flag --name not provided", + }, + { + args: args("logging datadog describe --service-id 123 --version 1 --name logs"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + GetDatadogFn: getDatadogError, + }, + wantError: errTest.Error(), + }, + { + args: args("logging datadog describe --service-id 123 --version 1 --name logs"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + GetDatadogFn: getDatadogOK, + }, + wantOutput: describeDatadogOutput, + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertString(t, testcase.wantOutput, stdout.String()) + }) + } +} + +func TestDatadogUpdate(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging datadog update --service-id 123 --version 1 --new-name log"), + wantError: "error parsing arguments: required flag --name not provided", + }, + { + args: args("logging datadog update --service-id 123 --version 1 --name logs --new-name log --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + UpdateDatadogFn: updateDatadogError, + }, + wantError: errTest.Error(), + }, + { + args: args("logging datadog update --service-id 123 --version 1 --name logs --new-name log --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + UpdateDatadogFn: updateDatadogOK, + }, + wantOutput: "Updated Datadog logging endpoint log (service 123 version 4)", + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertStringContains(t, stdout.String(), testcase.wantOutput) + }) + } +} + +func TestDatadogDelete(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging datadog delete --service-id 123 --version 1"), + wantError: "error parsing arguments: required flag --name not provided", + }, + { + args: args("logging datadog delete --service-id 123 --version 1 --name logs --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + DeleteDatadogFn: deleteDatadogError, + }, + wantError: errTest.Error(), + }, + { + args: args("logging datadog delete --service-id 123 --version 1 --name logs --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + DeleteDatadogFn: deleteDatadogOK, + }, + wantOutput: "Deleted Datadog logging endpoint logs (service 123 version 4)", + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertStringContains(t, stdout.String(), testcase.wantOutput) + }) + } +} + +var errTest = errors.New("fixture error") + +func createDatadogOK(i *fastly.CreateDatadogInput) (*fastly.Datadog, error) { + s := fastly.Datadog{ + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + } + + if i.Name != nil { + s.Name = i.Name + } + + return &s, nil +} + +func createDatadogError(_ *fastly.CreateDatadogInput) (*fastly.Datadog, error) { + return nil, errTest +} + +func listDatadogsOK(i *fastly.ListDatadogInput) ([]*fastly.Datadog, error) { + return []*fastly.Datadog{ + { + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("logs"), + Token: fastly.ToPointer("abc"), + Region: fastly.ToPointer("US"), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + FormatVersion: fastly.ToPointer(2), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + Placement: fastly.ToPointer("none"), + }, + { + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("analytics"), + Token: fastly.ToPointer("abc"), + Region: fastly.ToPointer("US"), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + FormatVersion: fastly.ToPointer(2), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + Placement: fastly.ToPointer("none"), + }, + }, nil +} + +func listDatadogsError(_ *fastly.ListDatadogInput) ([]*fastly.Datadog, error) { + return nil, errTest +} + +var listDatadogsShortOutput = strings.TrimSpace(` +SERVICE VERSION NAME +123 1 logs +123 1 analytics +`) + "\n" + +var listDatadogsVerboseOutput = strings.TrimSpace(` +Fastly API endpoint: https://api.fastly.com +Fastly API token provided via config file (profile: user) + +Service ID (via --service-id): 123 + +Version: 1 + Datadog 1/2 + Service ID: 123 + Version: 1 + Name: logs + Token: abc + Region: US + Format: %h %l %u %t "%r" %>s %b + Format version: 2 + Response condition: Prevent default logging + Placement: none + Datadog 2/2 + Service ID: 123 + Version: 1 + Name: analytics + Token: abc + Region: US + Format: %h %l %u %t "%r" %>s %b + Format version: 2 + Response condition: Prevent default logging + Placement: none +`) + "\n\n" + +func getDatadogOK(i *fastly.GetDatadogInput) (*fastly.Datadog, error) { + return &fastly.Datadog{ + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("logs"), + Token: fastly.ToPointer("abc"), + Region: fastly.ToPointer("US"), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + FormatVersion: fastly.ToPointer(2), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + Placement: fastly.ToPointer("none"), + }, nil +} + +func getDatadogError(_ *fastly.GetDatadogInput) (*fastly.Datadog, error) { + return nil, errTest +} + +var describeDatadogOutput = "\n" + strings.TrimSpace(` +Format: %h %l %u %t "%r" %>s %b +Format version: 2 +Name: logs +Placement: none +Region: US +Response condition: Prevent default logging +Service ID: 123 +Token: abc +Version: 1 +`) + "\n" + +func updateDatadogOK(i *fastly.UpdateDatadogInput) (*fastly.Datadog, error) { + return &fastly.Datadog{ + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("log"), + Token: fastly.ToPointer("abc"), + Region: fastly.ToPointer("US"), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + FormatVersion: fastly.ToPointer(2), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + }, nil +} + +func updateDatadogError(_ *fastly.UpdateDatadogInput) (*fastly.Datadog, error) { + return nil, errTest +} + +func deleteDatadogOK(_ *fastly.DeleteDatadogInput) error { + return nil +} + +func deleteDatadogError(_ *fastly.DeleteDatadogInput) error { + return errTest +} diff --git a/pkg/commands/logging/datadog/datadog_test.go b/pkg/commands/logging/datadog/datadog_test.go new file mode 100644 index 000000000..58659ba7c --- /dev/null +++ b/pkg/commands/logging/datadog/datadog_test.go @@ -0,0 +1,340 @@ +package datadog_test + +import ( + "bytes" + "testing" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/logging/datadog" + "github.com/fastly/cli/pkg/config" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" +) + +func TestCreateDatadogInput(t *testing.T) { + for _, testcase := range []struct { + name string + cmd *datadog.CreateCommand + want *fastly.CreateDatadogInput + wantError string + }{ + { + name: "required values set flag serviceID", + cmd: createCommandRequired(), + want: &fastly.CreateDatadogInput{ + ServiceID: "123", + ServiceVersion: 4, + Name: fastly.ToPointer("log"), + Token: fastly.ToPointer("tkn"), + }, + }, + { + name: "all values set flag serviceID", + cmd: createCommandOK(), + want: &fastly.CreateDatadogInput{ + ServiceID: "123", + ServiceVersion: 4, + Name: fastly.ToPointer("log"), + Region: fastly.ToPointer("US"), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + FormatVersion: fastly.ToPointer(2), + Token: fastly.ToPointer("tkn"), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + Placement: fastly.ToPointer("none"), + }, + }, + { + name: "error missing serviceID", + cmd: createCommandMissingServiceID(), + want: nil, + wantError: errors.ErrNoServiceID.Error(), + }, + } { + t.Run(testcase.name, func(t *testing.T) { + var bs []byte + out := bytes.NewBuffer(bs) + verboseMode := true + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + AutoCloneFlag: testcase.cmd.AutoClone, + APIClient: testcase.cmd.Globals.APIClient, + Manifest: testcase.cmd.Manifest, + Out: out, + ServiceVersionFlag: testcase.cmd.ServiceVersion, + VerboseMode: verboseMode, + }) + + switch { + case err != nil && testcase.wantError == "": + t.Fatalf("unexpected error getting service details: %v", err) + return + case err != nil && testcase.wantError != "": + testutil.AssertErrorContains(t, err, testcase.wantError) + return + case err == nil && testcase.wantError != "": + t.Fatalf("expected error, have nil (service details: %s, %d)", serviceID, serviceVersion.Number) + case err == nil && testcase.wantError == "": + have, err := testcase.cmd.ConstructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertEqual(t, testcase.want, have) + } + }) + } +} + +func TestUpdateDatadogInput(t *testing.T) { + scenarios := []struct { + name string + cmd *datadog.UpdateCommand + api mock.API + want *fastly.UpdateDatadogInput + wantError string + }{ + { + name: "no updates", + cmd: updateCommandNoUpdates(), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + GetDatadogFn: getDatadogOK, + }, + want: &fastly.UpdateDatadogInput{ + ServiceID: "123", + ServiceVersion: 4, + Name: "log", + }, + }, + { + name: "all values set flag serviceID", + cmd: updateCommandAll(), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + GetDatadogFn: getDatadogOK, + }, + want: &fastly.UpdateDatadogInput{ + ServiceID: "123", + ServiceVersion: 4, + Name: "log", + NewName: fastly.ToPointer("new1"), + Region: fastly.ToPointer("new2"), + Format: fastly.ToPointer("new3"), + FormatVersion: fastly.ToPointer(3), + Token: fastly.ToPointer("new4"), + ResponseCondition: fastly.ToPointer("new5"), + Placement: fastly.ToPointer("new6"), + }, + }, + { + name: "error missing serviceID", + cmd: updateCommandMissingServiceID(), + want: nil, + wantError: errors.ErrNoServiceID.Error(), + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(testcase.name, func(t *testing.T) { + testcase.cmd.Globals.APIClient = testcase.api + + var bs []byte + out := bytes.NewBuffer(bs) + verboseMode := true + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + AutoCloneFlag: testcase.cmd.AutoClone, + APIClient: testcase.api, + Manifest: testcase.cmd.Manifest, + Out: out, + ServiceVersionFlag: testcase.cmd.ServiceVersion, + VerboseMode: verboseMode, + }) + + switch { + case err != nil && testcase.wantError == "": + t.Fatalf("unexpected error getting service details: %v", err) + return + case err != nil && testcase.wantError != "": + testutil.AssertErrorContains(t, err, testcase.wantError) + return + case err == nil && testcase.wantError != "": + t.Fatalf("expected error, have nil (service details: %s, %d)", serviceID, serviceVersion.Number) + case err == nil && testcase.wantError == "": + have, err := testcase.cmd.ConstructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertEqual(t, testcase.want, have) + } + }) + } +} + +func createCommandOK() *datadog.CreateCommand { + var b bytes.Buffer + + g := global.Data{ + Config: config.File{}, + Env: config.Environment{}, + Output: &b, + } + g.APIClient, _ = mock.APIClient(mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + })("token", "endpoint", false) + + return &datadog.CreateCommand{ + Base: argparser.Base{ + Globals: &g, + }, + Manifest: manifest.Data{ + Flag: manifest.Flag{ + ServiceID: "123", + }, + }, + ServiceVersion: argparser.OptionalServiceVersion{ + OptionalString: argparser.OptionalString{Value: "1"}, + }, + AutoClone: argparser.OptionalAutoClone{ + OptionalBool: argparser.OptionalBool{ + Optional: argparser.Optional{ + WasSet: true, + }, + Value: true, + }, + }, + EndpointName: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "log"}, + Token: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "tkn"}, + Region: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "US"}, + Format: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: `%h %l %u %t "%r" %>s %b`}, + FormatVersion: argparser.OptionalInt{Optional: argparser.Optional{WasSet: true}, Value: 2}, + ResponseCondition: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "Prevent default logging"}, + Placement: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "none"}, + } +} + +func createCommandRequired() *datadog.CreateCommand { + var b bytes.Buffer + + g := global.Data{ + Config: config.File{}, + Env: config.Environment{}, + Output: &b, + } + g.APIClient, _ = mock.APIClient(mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + })("token", "endpoint", false) + + return &datadog.CreateCommand{ + Base: argparser.Base{ + Globals: &g, + }, + Manifest: manifest.Data{ + Flag: manifest.Flag{ + ServiceID: "123", + }, + }, + EndpointName: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "log"}, + Token: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "tkn"}, + ServiceVersion: argparser.OptionalServiceVersion{ + OptionalString: argparser.OptionalString{Value: "1"}, + }, + AutoClone: argparser.OptionalAutoClone{ + OptionalBool: argparser.OptionalBool{ + Optional: argparser.Optional{ + WasSet: true, + }, + Value: true, + }, + }, + } +} + +func createCommandMissingServiceID() *datadog.CreateCommand { + res := createCommandOK() + res.Manifest = manifest.Data{} + return res +} + +func updateCommandNoUpdates() *datadog.UpdateCommand { + var b bytes.Buffer + + g := global.Data{ + Config: config.File{}, + Env: config.Environment{}, + Output: &b, + } + + return &datadog.UpdateCommand{ + Base: argparser.Base{ + Globals: &g, + }, + Manifest: manifest.Data{ + Flag: manifest.Flag{ + ServiceID: "123", + }, + }, + EndpointName: "log", + ServiceVersion: argparser.OptionalServiceVersion{ + OptionalString: argparser.OptionalString{Value: "1"}, + }, + AutoClone: argparser.OptionalAutoClone{ + OptionalBool: argparser.OptionalBool{ + Optional: argparser.Optional{ + WasSet: true, + }, + Value: true, + }, + }, + } +} + +func updateCommandAll() *datadog.UpdateCommand { + var b bytes.Buffer + + g := global.Data{ + Config: config.File{}, + Env: config.Environment{}, + Output: &b, + } + + return &datadog.UpdateCommand{ + Base: argparser.Base{ + Globals: &g, + }, + Manifest: manifest.Data{ + Flag: manifest.Flag{ + ServiceID: "123", + }, + }, + EndpointName: "log", + ServiceVersion: argparser.OptionalServiceVersion{ + OptionalString: argparser.OptionalString{Value: "1"}, + }, + AutoClone: argparser.OptionalAutoClone{ + OptionalBool: argparser.OptionalBool{ + Optional: argparser.Optional{ + WasSet: true, + }, + Value: true, + }, + }, + NewName: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new1"}, + Region: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new2"}, + Format: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new3"}, + FormatVersion: argparser.OptionalInt{Optional: argparser.Optional{WasSet: true}, Value: 3}, + Token: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new4"}, + ResponseCondition: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new5"}, + Placement: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new6"}, + } +} + +func updateCommandMissingServiceID() *datadog.UpdateCommand { + res := updateCommandAll() + res.Manifest = manifest.Data{} + return res +} diff --git a/pkg/commands/logging/datadog/delete.go b/pkg/commands/logging/datadog/delete.go new file mode 100644 index 000000000..262c289f4 --- /dev/null +++ b/pkg/commands/logging/datadog/delete.go @@ -0,0 +1,94 @@ +package datadog + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// DeleteCommand calls the Fastly API to delete a Datadog logging endpoint. +type DeleteCommand struct { + argparser.Base + Input fastly.DeleteDatadogInput + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion + autoClone argparser.OptionalAutoClone +} + +// NewDeleteCommand returns a usable command registered under the parent. +func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { + c := DeleteCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("delete", "Delete a Datadog logging endpoint on a Fastly service version").Alias("remove") + + // Required. + c.CmdClause.Flag("name", "The name of the Datadog logging object").Short('n').Required().StringVar(&c.Input.Name) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.autoClone.Set, + Dst: &c.autoClone.Value, + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + return &c +} + +// Exec invokes the application logic for the command. +func (c *DeleteCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.autoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + c.Input.ServiceID = serviceID + c.Input.ServiceVersion = fastly.ToValue(serviceVersion.Number) + + if err := c.Globals.APIClient.DeleteDatadog(&c.Input); err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + text.Success(out, "Deleted Datadog logging endpoint %s (service %s version %d)", c.Input.Name, c.Input.ServiceID, c.Input.ServiceVersion) + return nil +} diff --git a/pkg/commands/logging/datadog/describe.go b/pkg/commands/logging/datadog/describe.go new file mode 100644 index 000000000..361346331 --- /dev/null +++ b/pkg/commands/logging/datadog/describe.go @@ -0,0 +1,110 @@ +package datadog + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// DescribeCommand calls the Fastly API to describe a Datadog logging endpoint. +type DescribeCommand struct { + argparser.Base + argparser.JSONOutput + + Input fastly.GetDatadogInput + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion +} + +// NewDescribeCommand returns a usable command registered under the parent. +func NewDescribeCommand(parent argparser.Registerer, g *global.Data) *DescribeCommand { + c := DescribeCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("describe", "Show detailed information about a Datadog logging endpoint on a Fastly service version").Alias("get") + + // Required. + c.CmdClause.Flag("name", "The name of the Datadog logging object").Short('n').Required().StringVar(&c.Input.Name) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + return &c +} + +// Exec invokes the application logic for the command. +func (c *DescribeCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fsterr.ServiceVersion(serviceVersion), + }) + return err + } + + c.Input.ServiceID = serviceID + c.Input.ServiceVersion = fastly.ToValue(serviceVersion.Number) + + o, err := c.Globals.APIClient.GetDatadog(&c.Input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + lines := text.Lines{ + "Format version": fastly.ToValue(o.FormatVersion), + "Format": fastly.ToValue(o.Format), + "Name": fastly.ToValue(o.Name), + "Placement": fastly.ToValue(o.Placement), + "Region": fastly.ToValue(o.Region), + "Response condition": fastly.ToValue(o.ResponseCondition), + "Token": fastly.ToValue(o.Token), + "Version": fastly.ToValue(o.ServiceVersion), + } + if !c.Globals.Verbose() { + lines["Service ID"] = fastly.ToValue(o.ServiceID) + } + text.PrintLines(out, lines) + + return nil +} diff --git a/pkg/logging/datadog/doc.go b/pkg/commands/logging/datadog/doc.go similarity index 100% rename from pkg/logging/datadog/doc.go rename to pkg/commands/logging/datadog/doc.go diff --git a/pkg/commands/logging/datadog/list.go b/pkg/commands/logging/datadog/list.go new file mode 100644 index 000000000..f527a6a9b --- /dev/null +++ b/pkg/commands/logging/datadog/list.go @@ -0,0 +1,124 @@ +package datadog + +import ( + "fmt" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// ListCommand calls the Fastly API to list Datadog logging endpoints. +type ListCommand struct { + argparser.Base + argparser.JSONOutput + + Input fastly.ListDatadogInput + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion +} + +// NewListCommand returns a usable command registered under the parent. +func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { + c := ListCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("list", "List Datadog endpoints on a Fastly service version") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + return &c +} + +// Exec invokes the application logic for the command. +func (c *ListCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fsterr.ServiceVersion(serviceVersion), + }) + return err + } + + c.Input.ServiceID = serviceID + c.Input.ServiceVersion = fastly.ToValue(serviceVersion.Number) + + o, err := c.Globals.APIClient.ListDatadog(&c.Input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + if !c.Globals.Verbose() { + tw := text.NewTable(out) + tw.AddHeader("SERVICE", "VERSION", "NAME") + for _, datadog := range o { + tw.AddLine( + fastly.ToValue(datadog.ServiceID), + fastly.ToValue(datadog.ServiceVersion), + fastly.ToValue(datadog.Name), + ) + } + tw.Print() + return nil + } + + fmt.Fprintf(out, "Version: %d\n", c.Input.ServiceVersion) + for i, datadog := range o { + fmt.Fprintf(out, "\tDatadog %d/%d\n", i+1, len(o)) + fmt.Fprintf(out, "\t\tService ID: %s\n", fastly.ToValue(datadog.ServiceID)) + fmt.Fprintf(out, "\t\tVersion: %d\n", fastly.ToValue(datadog.ServiceVersion)) + fmt.Fprintf(out, "\t\tName: %s\n", fastly.ToValue(datadog.Name)) + fmt.Fprintf(out, "\t\tToken: %s\n", fastly.ToValue(datadog.Token)) + fmt.Fprintf(out, "\t\tRegion: %s\n", fastly.ToValue(datadog.Region)) + fmt.Fprintf(out, "\t\tFormat: %s\n", fastly.ToValue(datadog.Format)) + fmt.Fprintf(out, "\t\tFormat version: %d\n", fastly.ToValue(datadog.FormatVersion)) + fmt.Fprintf(out, "\t\tResponse condition: %s\n", fastly.ToValue(datadog.ResponseCondition)) + fmt.Fprintf(out, "\t\tPlacement: %s\n", fastly.ToValue(datadog.Placement)) + } + fmt.Fprintln(out) + + return nil +} diff --git a/pkg/commands/logging/datadog/root.go b/pkg/commands/logging/datadog/root.go new file mode 100644 index 000000000..985b2f197 --- /dev/null +++ b/pkg/commands/logging/datadog/root.go @@ -0,0 +1,31 @@ +package datadog + +import ( + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" +) + +// RootCommand is the parent command for all subcommands in this package. +// It should be installed under the primary root command. +type RootCommand struct { + argparser.Base + // no flags +} + +// CommandName is the string to be used to invoke this command. +const CommandName = "datadog" + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent argparser.Registerer, g *global.Data) *RootCommand { + var c RootCommand + c.Globals = g + c.CmdClause = parent.Command(CommandName, "Manipulate Fastly service version Datadog logging endpoints") + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { + panic("unreachable") +} diff --git a/pkg/commands/logging/datadog/update.go b/pkg/commands/logging/datadog/update.go new file mode 100644 index 000000000..9cdda8a48 --- /dev/null +++ b/pkg/commands/logging/datadog/update.go @@ -0,0 +1,163 @@ +package datadog + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/logging/common" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/text" +) + +// UpdateCommand calls the Fastly API to update a Datadog logging endpoint. +type UpdateCommand struct { + argparser.Base + Manifest manifest.Data + + // Required. + EndpointName string // Can't shadow argparser.Base method Name(). + ServiceName argparser.OptionalServiceNameID + ServiceVersion argparser.OptionalServiceVersion + + // Optional. + AutoClone argparser.OptionalAutoClone + NewName argparser.OptionalString + Token argparser.OptionalString + Region argparser.OptionalString + Format argparser.OptionalString + FormatVersion argparser.OptionalInt + ResponseCondition argparser.OptionalString + Placement argparser.OptionalString +} + +// NewUpdateCommand returns a usable command registered under the parent. +func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { + c := UpdateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("update", "Update a Datadog logging endpoint on a Fastly service version") + + // Required. + c.CmdClause.Flag("name", "The name of the Datadog logging object").Short('n').Required().StringVar(&c.EndpointName) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.ServiceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.AutoClone.Set, + Dst: &c.AutoClone.Value, + }) + c.CmdClause.Flag("auth-token", "The API key from your Datadog account").Action(c.Token.Set).StringVar(&c.Token.Value) + common.Format(c.CmdClause, &c.Format) + common.FormatVersion(c.CmdClause, &c.FormatVersion) + c.CmdClause.Flag("new-name", "New name of the Datadog logging object").Action(c.NewName.Set).StringVar(&c.NewName.Value) + common.Placement(c.CmdClause, &c.Placement) + c.CmdClause.Flag("region", "The region that log data will be sent to. One of US, US3, US5, or EU. Defaults to US if undefined").Action(c.Region.Set).StringVar(&c.Region.Value) + common.ResponseCondition(c.CmdClause, &c.ResponseCondition) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.ServiceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.ServiceName.Value, + }) + return &c +} + +// ConstructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *UpdateCommand) ConstructInput(serviceID string, serviceVersion int) (*fastly.UpdateDatadogInput, error) { + input := fastly.UpdateDatadogInput{ + ServiceID: serviceID, + ServiceVersion: serviceVersion, + Name: c.EndpointName, + } + + if c.NewName.WasSet { + input.NewName = &c.NewName.Value + } + + if c.Token.WasSet { + input.Token = &c.Token.Value + } + + if c.Region.WasSet { + input.Region = &c.Region.Value + } + + if c.Format.WasSet { + input.Format = fastly.ToPointer(argparser.Content(c.Format.Value)) + } + + if c.FormatVersion.WasSet { + input.FormatVersion = &c.FormatVersion.Value + } + + if c.ResponseCondition.WasSet { + input.ResponseCondition = &c.ResponseCondition.Value + } + + if c.Placement.WasSet { + input.Placement = &c.Placement.Value + } + + return &input, nil +} + +// Exec invokes the application logic for the command. +func (c *UpdateCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.AutoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.ServiceName, + ServiceVersionFlag: c.ServiceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + input, err := c.ConstructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + datadog, err := c.Globals.APIClient.UpdateDatadog(input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + text.Success(out, + "Updated Datadog logging endpoint %s (service %s version %d)", + fastly.ToValue(datadog.Name), + fastly.ToValue(datadog.ServiceID), + fastly.ToValue(datadog.ServiceVersion), + ) + return nil +} diff --git a/pkg/commands/logging/digitalocean/create.go b/pkg/commands/logging/digitalocean/create.go new file mode 100644 index 000000000..579796303 --- /dev/null +++ b/pkg/commands/logging/digitalocean/create.go @@ -0,0 +1,217 @@ +package digitalocean + +import ( + "fmt" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/logging/common" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/text" +) + +// CreateCommand calls the Fastly API to create a DigitalOcean Spaces logging endpoint. +type CreateCommand struct { + argparser.Base + Manifest manifest.Data + + // Required. + ServiceName argparser.OptionalServiceNameID + ServiceVersion argparser.OptionalServiceVersion + + // Optional. + AccessKey argparser.OptionalString + AutoClone argparser.OptionalAutoClone + BucketName argparser.OptionalString + CompressionCodec argparser.OptionalString + Domain argparser.OptionalString + EndpointName argparser.OptionalString // Can't shadow argparser.Base method Name(). + Format argparser.OptionalString + FormatVersion argparser.OptionalInt + GzipLevel argparser.OptionalInt + MessageType argparser.OptionalString + Path argparser.OptionalString + Period argparser.OptionalInt + Placement argparser.OptionalString + PublicKey argparser.OptionalString + ResponseCondition argparser.OptionalString + SecretKey argparser.OptionalString + TimestampFormat argparser.OptionalString +} + +// NewCreateCommand returns a usable command registered under the parent. +func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { + c := CreateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("create", "Create a DigitalOcean Spaces logging endpoint on a Fastly service version").Alias("add") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.ServiceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.AutoClone.Set, + Dst: &c.AutoClone.Value, + }) + c.CmdClause.Flag("access-key", "Your DigitalOcean Spaces account access key").Action(c.AccessKey.Set).StringVar(&c.AccessKey.Value) + c.CmdClause.Flag("bucket", "The name of the DigitalOcean Space").Action(c.BucketName.Set).StringVar(&c.BucketName.Value) + common.CompressionCodec(c.CmdClause, &c.CompressionCodec) + c.CmdClause.Flag("domain", "The domain of the DigitalOcean Spaces endpoint (default 'nyc3.digitaloceanspaces.com')").Action(c.Domain.Set).StringVar(&c.Domain.Value) + common.Format(c.CmdClause, &c.Format) + common.FormatVersion(c.CmdClause, &c.FormatVersion) + common.GzipLevel(c.CmdClause, &c.GzipLevel) + c.CmdClause.Flag("name", "The name of the DigitalOcean Spaces logging object. Used as a primary key for API access").Short('n').Action(c.EndpointName.Set).StringVar(&c.EndpointName.Value) + common.MessageType(c.CmdClause, &c.MessageType) + common.Path(c.CmdClause, &c.Path) + common.Period(c.CmdClause, &c.Period) + common.Placement(c.CmdClause, &c.Placement) + common.PublicKey(c.CmdClause, &c.PublicKey) + common.ResponseCondition(c.CmdClause, &c.ResponseCondition) + c.CmdClause.Flag("secret-key", "Your DigitalOcean Spaces account secret key").Action(c.SecretKey.Set).StringVar(&c.SecretKey.Value) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.ServiceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.ServiceName.Value, + }) + common.TimestampFormat(c.CmdClause, &c.TimestampFormat) + return &c +} + +// ConstructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *CreateCommand) ConstructInput(serviceID string, serviceVersion int) (*fastly.CreateDigitalOceanInput, error) { + var input fastly.CreateDigitalOceanInput + + input.ServiceID = serviceID + input.ServiceVersion = serviceVersion + if c.EndpointName.WasSet { + input.Name = &c.EndpointName.Value + } + if c.BucketName.WasSet { + input.BucketName = &c.BucketName.Value + } + if c.AccessKey.WasSet { + input.AccessKey = &c.AccessKey.Value + } + if c.SecretKey.WasSet { + input.SecretKey = &c.SecretKey.Value + } + + // The following blocks enforces the mutual exclusivity of the + // CompressionCodec and GzipLevel flags. + if c.CompressionCodec.WasSet && c.GzipLevel.WasSet { + return nil, fmt.Errorf("error parsing arguments: the --compression-codec flag is mutually exclusive with the --gzip-level flag") + } + + if c.Domain.WasSet { + input.Domain = &c.Domain.Value + } + + if c.Path.WasSet { + input.Path = &c.Path.Value + } + + if c.Period.WasSet { + input.Period = &c.Period.Value + } + + if c.GzipLevel.WasSet { + input.GzipLevel = &c.GzipLevel.Value + } + + if c.Format.WasSet { + input.Format = fastly.ToPointer(argparser.Content(c.Format.Value)) + } + + if c.FormatVersion.WasSet { + input.FormatVersion = &c.FormatVersion.Value + } + + if c.ResponseCondition.WasSet { + input.ResponseCondition = &c.ResponseCondition.Value + } + + if c.MessageType.WasSet { + input.MessageType = &c.MessageType.Value + } + + if c.TimestampFormat.WasSet { + input.TimestampFormat = &c.TimestampFormat.Value + } + + if c.Placement.WasSet { + input.Placement = &c.Placement.Value + } + + if c.PublicKey.WasSet { + input.PublicKey = &c.PublicKey.Value + } + + if c.CompressionCodec.WasSet { + input.CompressionCodec = &c.CompressionCodec.Value + } + + return &input, nil +} + +// Exec invokes the application logic for the command. +func (c *CreateCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.AutoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.ServiceName, + ServiceVersionFlag: c.ServiceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + input, err := c.ConstructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + d, err := c.Globals.APIClient.CreateDigitalOcean(input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + text.Success(out, + "Created DigitalOcean Spaces logging endpoint %s (service %s version %d)", + fastly.ToValue(d.Name), + fastly.ToValue(d.ServiceID), + fastly.ToValue(d.ServiceVersion), + ) + return nil +} diff --git a/pkg/commands/logging/digitalocean/delete.go b/pkg/commands/logging/digitalocean/delete.go new file mode 100644 index 000000000..186d3ff59 --- /dev/null +++ b/pkg/commands/logging/digitalocean/delete.go @@ -0,0 +1,94 @@ +package digitalocean + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// DeleteCommand calls the Fastly API to delete a DigitalOcean Spaces logging endpoint. +type DeleteCommand struct { + argparser.Base + Input fastly.DeleteDigitalOceanInput + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion + autoClone argparser.OptionalAutoClone +} + +// NewDeleteCommand returns a usable command registered under the parent. +func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { + c := DeleteCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("delete", "Delete a DigitalOcean Spaces logging endpoint on a Fastly service version").Alias("remove") + + // Required. + c.CmdClause.Flag("name", "The name of the DigitalOcean Spaces logging object").Short('n').Required().StringVar(&c.Input.Name) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.autoClone.Set, + Dst: &c.autoClone.Value, + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + return &c +} + +// Exec invokes the application logic for the command. +func (c *DeleteCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.autoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + c.Input.ServiceID = serviceID + c.Input.ServiceVersion = fastly.ToValue(serviceVersion.Number) + + if err := c.Globals.APIClient.DeleteDigitalOcean(&c.Input); err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + text.Success(out, "Deleted DigitalOcean Spaces logging endpoint %s (service %s version %d)", c.Input.Name, c.Input.ServiceID, c.Input.ServiceVersion) + return nil +} diff --git a/pkg/commands/logging/digitalocean/describe.go b/pkg/commands/logging/digitalocean/describe.go new file mode 100644 index 000000000..c1d29d69f --- /dev/null +++ b/pkg/commands/logging/digitalocean/describe.go @@ -0,0 +1,118 @@ +package digitalocean + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// DescribeCommand calls the Fastly API to describe a DigitalOcean Spaces logging endpoint. +type DescribeCommand struct { + argparser.Base + argparser.JSONOutput + + Input fastly.GetDigitalOceanInput + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion +} + +// NewDescribeCommand returns a usable command registered under the parent. +func NewDescribeCommand(parent argparser.Registerer, g *global.Data) *DescribeCommand { + c := DescribeCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("describe", "Show detailed information about a DigitalOcean Spaces logging endpoint on a Fastly service version").Alias("get") + + // Required. + c.CmdClause.Flag("name", "The name of the DigitalOcean Spaces logging object").Short('n').Required().StringVar(&c.Input.Name) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + return &c +} + +// Exec invokes the application logic for the command. +func (c *DescribeCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fsterr.ServiceVersion(serviceVersion), + }) + return err + } + + c.Input.ServiceID = serviceID + c.Input.ServiceVersion = fastly.ToValue(serviceVersion.Number) + + o, err := c.Globals.APIClient.GetDigitalOcean(&c.Input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + lines := text.Lines{ + "Access key": fastly.ToValue(o.AccessKey), + "Bucket": fastly.ToValue(o.BucketName), + "Domain": fastly.ToValue(o.Domain), + "Format version": fastly.ToValue(o.FormatVersion), + "Format": fastly.ToValue(o.Format), + "GZip level": fastly.ToValue(o.GzipLevel), + "Message type": fastly.ToValue(o.MessageType), + "Name": fastly.ToValue(o.Name), + "Path": fastly.ToValue(o.Path), + "Period": fastly.ToValue(o.Period), + "Placement": fastly.ToValue(o.Placement), + "Public key": fastly.ToValue(o.PublicKey), + "Response condition": fastly.ToValue(o.ResponseCondition), + "Secret key": fastly.ToValue(o.SecretKey), + "Timestamp format": fastly.ToValue(o.TimestampFormat), + "Version": fastly.ToValue(o.ServiceVersion), + } + if !c.Globals.Verbose() { + lines["Service ID"] = fastly.ToValue(o.ServiceID) + } + text.PrintLines(out, lines) + + return nil +} diff --git a/pkg/commands/logging/digitalocean/digitalocean_integration_test.go b/pkg/commands/logging/digitalocean/digitalocean_integration_test.go new file mode 100644 index 000000000..63bcd7a49 --- /dev/null +++ b/pkg/commands/logging/digitalocean/digitalocean_integration_test.go @@ -0,0 +1,510 @@ +package digitalocean_test + +import ( + "bytes" + "errors" + "io" + "strings" + "testing" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/app" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" +) + +func TestDigitalOceanCreate(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging digitalocean create --service-id 123 --version 1 --name log --bucket log --access-key foo --secret-key abc --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + CreateDigitalOceanFn: createDigitalOceanOK, + }, + wantOutput: "Created DigitalOcean Spaces logging endpoint log (service 123 version 4)", + }, + { + args: args("logging digitalocean create --service-id 123 --version 1 --name log --bucket log --access-key foo --secret-key abc --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + CreateDigitalOceanFn: createDigitalOceanError, + }, + wantError: errTest.Error(), + }, + { + args: args("logging digitalocean create --service-id 123 --version 1 --name log --bucket log --access-key foo --secret-key abc --compression-codec zstd --gzip-level 9 --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + }, + wantError: "error parsing arguments: the --compression-codec flag is mutually exclusive with the --gzip-level flag", + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertStringContains(t, stdout.String(), testcase.wantOutput) + }) + } +} + +func TestDigitalOceanList(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging digitalocean list --service-id 123 --version 1"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListDigitalOceansFn: listDigitalOceansOK, + }, + wantOutput: listDigitalOceansShortOutput, + }, + { + args: args("logging digitalocean list --service-id 123 --version 1 --verbose"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListDigitalOceansFn: listDigitalOceansOK, + }, + wantOutput: listDigitalOceansVerboseOutput, + }, + { + args: args("logging digitalocean list --service-id 123 --version 1 -v"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListDigitalOceansFn: listDigitalOceansOK, + }, + wantOutput: listDigitalOceansVerboseOutput, + }, + { + args: args("logging digitalocean --verbose list --service-id 123 --version 1"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListDigitalOceansFn: listDigitalOceansOK, + }, + wantOutput: listDigitalOceansVerboseOutput, + }, + { + args: args("logging -v digitalocean list --service-id 123 --version 1"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListDigitalOceansFn: listDigitalOceansOK, + }, + wantOutput: listDigitalOceansVerboseOutput, + }, + { + args: args("logging digitalocean list --service-id 123 --version 1"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListDigitalOceansFn: listDigitalOceansError, + }, + wantError: errTest.Error(), + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertString(t, testcase.wantOutput, stdout.String()) + }) + } +} + +func TestDigitalOceanDescribe(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging digitalocean describe --service-id 123 --version 1"), + wantError: "error parsing arguments: required flag --name not provided", + }, + { + args: args("logging digitalocean describe --service-id 123 --version 1 --name logs"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + GetDigitalOceanFn: getDigitalOceanError, + }, + wantError: errTest.Error(), + }, + { + args: args("logging digitalocean describe --service-id 123 --version 1 --name logs"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + GetDigitalOceanFn: getDigitalOceanOK, + }, + wantOutput: describeDigitalOceanOutput, + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertString(t, testcase.wantOutput, stdout.String()) + }) + } +} + +func TestDigitalOceanUpdate(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging digitalocean update --service-id 123 --version 1 --new-name log"), + wantError: "error parsing arguments: required flag --name not provided", + }, + { + args: args("logging digitalocean update --service-id 123 --version 1 --name logs --new-name log --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + UpdateDigitalOceanFn: updateDigitalOceanError, + }, + wantError: errTest.Error(), + }, + { + args: args("logging digitalocean update --service-id 123 --version 1 --name logs --new-name log --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + UpdateDigitalOceanFn: updateDigitalOceanOK, + }, + wantOutput: "Updated DigitalOcean Spaces logging endpoint log (service 123 version 4)", + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertStringContains(t, stdout.String(), testcase.wantOutput) + }) + } +} + +func TestDigitalOceanDelete(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging digitalocean delete --service-id 123 --version 1"), + wantError: "error parsing arguments: required flag --name not provided", + }, + { + args: args("logging digitalocean delete --service-id 123 --version 1 --name logs --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + DeleteDigitalOceanFn: deleteDigitalOceanError, + }, + wantError: errTest.Error(), + }, + { + args: args("logging digitalocean delete --service-id 123 --version 1 --name logs --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + DeleteDigitalOceanFn: deleteDigitalOceanOK, + }, + wantOutput: "Deleted DigitalOcean Spaces logging endpoint logs (service 123 version 4)", + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertStringContains(t, stdout.String(), testcase.wantOutput) + }) + } +} + +var errTest = errors.New("fixture error") + +func createDigitalOceanOK(i *fastly.CreateDigitalOceanInput) (*fastly.DigitalOcean, error) { + s := fastly.DigitalOcean{ + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + } + + if i.Name != nil { + s.Name = i.Name + } + + return &s, nil +} + +func createDigitalOceanError(_ *fastly.CreateDigitalOceanInput) (*fastly.DigitalOcean, error) { + return nil, errTest +} + +func listDigitalOceansOK(i *fastly.ListDigitalOceansInput) ([]*fastly.DigitalOcean, error) { + return []*fastly.DigitalOcean{ + { + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("logs"), + BucketName: fastly.ToPointer("my-logs"), + Domain: fastly.ToPointer("https://digitalocean.us-east-1.amazonaws.com"), + AccessKey: fastly.ToPointer("1234"), + SecretKey: fastly.ToPointer("-----BEGIN RSA PRIVATE KEY-----MIIEogIBAAKCA"), + Path: fastly.ToPointer("logs/"), + Period: fastly.ToPointer(3600), + GzipLevel: fastly.ToPointer(9), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + FormatVersion: fastly.ToPointer(2), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + MessageType: fastly.ToPointer("classic"), + TimestampFormat: fastly.ToPointer("%Y-%m-%dT%H:%M:%S.000"), + Placement: fastly.ToPointer("none"), + PublicKey: fastly.ToPointer(pgpPublicKey()), + }, + { + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("analytics"), + BucketName: fastly.ToPointer("analytics"), + AccessKey: fastly.ToPointer("1234"), + SecretKey: fastly.ToPointer("-----BEGIN RSA PRIVATE KEY-----MIIEogIBAAKCA"), + Domain: fastly.ToPointer("https://digitalocean.us-east-2.amazonaws.com"), + Path: fastly.ToPointer("logs/"), + Period: fastly.ToPointer(86400), + GzipLevel: fastly.ToPointer(9), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + FormatVersion: fastly.ToPointer(2), + MessageType: fastly.ToPointer("classic"), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + TimestampFormat: fastly.ToPointer("%Y-%m-%dT%H:%M:%S.000"), + Placement: fastly.ToPointer("none"), + PublicKey: fastly.ToPointer(pgpPublicKey()), + }, + }, nil +} + +func listDigitalOceansError(_ *fastly.ListDigitalOceansInput) ([]*fastly.DigitalOcean, error) { + return nil, errTest +} + +var listDigitalOceansShortOutput = strings.TrimSpace(` +SERVICE VERSION NAME +123 1 logs +123 1 analytics +`) + "\n" + +var listDigitalOceansVerboseOutput = strings.TrimSpace(` +Fastly API endpoint: https://api.fastly.com +Fastly API token provided via config file (profile: user) + +Service ID (via --service-id): 123 + +Version: 1 + DigitalOcean 1/2 + Service ID: 123 + Version: 1 + Name: logs + Bucket: my-logs + Domain: https://digitalocean.us-east-1.amazonaws.com + Access key: 1234 + Secret key: -----BEGIN RSA PRIVATE KEY-----MIIEogIBAAKCA + Path: logs/ + Period: 3600 + GZip level: 9 + Format: %h %l %u %t "%r" %>s %b + Format version: 2 + Response condition: Prevent default logging + Message type: classic + Timestamp format: %Y-%m-%dT%H:%M:%S.000 + Placement: none + Public key: `+pgpPublicKey()+` + DigitalOcean 2/2 + Service ID: 123 + Version: 1 + Name: analytics + Bucket: analytics + Domain: https://digitalocean.us-east-2.amazonaws.com + Access key: 1234 + Secret key: -----BEGIN RSA PRIVATE KEY-----MIIEogIBAAKCA + Path: logs/ + Period: 86400 + GZip level: 9 + Format: %h %l %u %t "%r" %>s %b + Format version: 2 + Response condition: Prevent default logging + Message type: classic + Timestamp format: %Y-%m-%dT%H:%M:%S.000 + Placement: none + Public key: `+pgpPublicKey()+` +`) + "\n\n" + +func getDigitalOceanOK(i *fastly.GetDigitalOceanInput) (*fastly.DigitalOcean, error) { + return &fastly.DigitalOcean{ + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("logs"), + BucketName: fastly.ToPointer("my-logs"), + Domain: fastly.ToPointer("https://digitalocean.us-east-1.amazonaws.com"), + AccessKey: fastly.ToPointer("1234"), + SecretKey: fastly.ToPointer("-----BEGIN RSA PRIVATE KEY-----MIIEogIBAAKCA"), + Path: fastly.ToPointer("logs/"), + Period: fastly.ToPointer(3600), + GzipLevel: fastly.ToPointer(9), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + FormatVersion: fastly.ToPointer(2), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + MessageType: fastly.ToPointer("classic"), + TimestampFormat: fastly.ToPointer("%Y-%m-%dT%H:%M:%S.000"), + Placement: fastly.ToPointer("none"), + PublicKey: fastly.ToPointer(pgpPublicKey()), + }, nil +} + +func getDigitalOceanError(_ *fastly.GetDigitalOceanInput) (*fastly.DigitalOcean, error) { + return nil, errTest +} + +var describeDigitalOceanOutput = "\n" + strings.TrimSpace(` +Access key: 1234 +Bucket: my-logs +Domain: https://digitalocean.us-east-1.amazonaws.com +Format: %h %l %u %t "%r" %>s %b +Format version: 2 +GZip level: 9 +Message type: classic +Name: logs +Path: logs/ +Period: 3600 +Placement: none +Public key: `+pgpPublicKey()+` +Response condition: Prevent default logging +Secret key: -----BEGIN RSA PRIVATE KEY-----MIIEogIBAAKCA +Service ID: 123 +Timestamp format: %Y-%m-%dT%H:%M:%S.000 +Version: 1 +`) + "\n" + +func updateDigitalOceanOK(i *fastly.UpdateDigitalOceanInput) (*fastly.DigitalOcean, error) { + return &fastly.DigitalOcean{ + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("log"), + BucketName: fastly.ToPointer("my-logs"), + Domain: fastly.ToPointer("https://digitalocean.us-east-1.amazonaws.com"), + AccessKey: fastly.ToPointer("1234"), + SecretKey: fastly.ToPointer("-----BEGIN RSA PRIVATE KEY-----MIIEogIBAAKCA"), + Path: fastly.ToPointer("logs/"), + Period: fastly.ToPointer(3600), + GzipLevel: fastly.ToPointer(9), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + FormatVersion: fastly.ToPointer(2), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + MessageType: fastly.ToPointer("classic"), + TimestampFormat: fastly.ToPointer("%Y-%m-%dT%H:%M:%S.000"), + Placement: fastly.ToPointer("none"), + PublicKey: fastly.ToPointer(pgpPublicKey()), + }, nil +} + +func updateDigitalOceanError(_ *fastly.UpdateDigitalOceanInput) (*fastly.DigitalOcean, error) { + return nil, errTest +} + +func deleteDigitalOceanOK(_ *fastly.DeleteDigitalOceanInput) error { + return nil +} + +func deleteDigitalOceanError(_ *fastly.DeleteDigitalOceanInput) error { + return errTest +} + +// pgpPublicKey returns a PEM encoded PGP public key suitable for testing. +func pgpPublicKey() string { + return strings.TrimSpace(`-----BEGIN PGP PUBLIC KEY BLOCK----- +mQENBFyUD8sBCACyFnB39AuuTygseek+eA4fo0cgwva6/FSjnWq7riouQee8GgQ/ +ibXTRyv4iVlwI12GswvMTIy7zNvs1R54i0qvsLr+IZ4GVGJqs6ZJnvQcqe3xPoR4 +8AnBfw90o32r/LuHf6QCJXi+AEu35koNlNAvLJ2B+KACaNB7N0EeWmqpV/1V2k9p +lDYk+th7LcCuaFNGqKS/PrMnnMqR6VDLCjHhNx4KR79b0Twm/2qp6an3hyNRu8Gn +dwxpf1/BUu3JWf+LqkN4Y3mbOmSUL3MaJNvyQguUzTfS0P0uGuBDHrJCVkMZCzDB +89ag55jCPHyGeHBTd02gHMWzsg3WMBWvCsrzABEBAAG0JXRlcnJhZm9ybSAodGVz +dCkgPHRlc3RAdGVycmFmb3JtLmNvbT6JAU4EEwEIADgWIQSHYyc6Kj9l6HzQsau6 +vFFc9jxV/wUCXJQPywIbAwULCQgHAgYVCgkICwIEFgIDAQIeAQIXgAAKCRC6vFFc +9jxV/815CAClb32OxV7wG01yF97TzlyTl8TnvjMtoG29Mw4nSyg+mjM3b8N7iXm9 +OLX59fbDAWtBSldSZE22RXd3CvlFOG/EnKBXSjBtEqfyxYSnyOPkMPBYWGL/ApkX +SvPYJ4LKdvipYToKFh3y9kk2gk1DcDBDyaaHvR+3rv1u3aoy7/s2EltAfDS3ZQIq +7/cWTLJml/lleeB/Y6rPj8xqeCYhE5ahw9gsV/Mdqatl24V9Tks30iijx0Hhw+Gx +kATUikMGr2GDVqoIRga5kXI7CzYff4rkc0Twn47fMHHHe/KY9M2yVnMHUXmAZwbG +M1cMI/NH1DjevCKdGBLcRJlhuLPKF/anuQENBFyUD8sBCADIpd7r7GuPd6n/Ikxe +u6h7umV6IIPoAm88xCYpTbSZiaK30Svh6Ywra9jfE2KlU9o6Y/art8ip0VJ3m07L +4RSfSpnzqgSwdjSq5hNour2Fo/BzYhK7yaz2AzVSbe33R0+RYhb4b/6N+bKbjwGF +ftCsqVFMH+PyvYkLbvxyQrHlA9woAZaNThI1ztO5rGSnGUR8xt84eup28WIFKg0K +UEGUcTzz+8QGAwAra+0ewPXo/AkO+8BvZjDidP417u6gpBHOJ9qYIcO9FxHeqFyu +YrjlrxowEgXn5wO8xuNz6Vu1vhHGDHGDsRbZF8pv1d5O+0F1G7ttZ2GRRgVBZPwi +kiyRABEBAAGJATYEGAEIACAWIQSHYyc6Kj9l6HzQsau6vFFc9jxV/wUCXJQPywIb +DAAKCRC6vFFc9jxV/9YOCACe8qmOSnKQpQfW+PqYOqo3dt7JyweTs3FkD6NT8Zml +dYy/vkstbTjPpX6aTvUZjkb46BVi7AOneVHpD5GBqvRsZ9iVgDYHaehmLCdKiG5L +3Tp90NN+QY5WDbsGmsyk6+6ZMYejb4qYfweQeduOj27aavCJdLkCYMoRKfcFYI8c +FaNmEfKKy/r1PO20NXEG6t9t05K/frHy6ZG8bCNYdpagfFVot47r9JaQqWlTNtIR +5+zkkSq/eG9BEtRij3a6cTdQbktdBzx2KBeI0PYc1vlZR0LpuFKZqY9vlE6vTGLR +wMfrTEOvx0NxUM3rpaCgEmuWbB1G1Hu371oyr4srrr+N +=28dr +-----END PGP PUBLIC KEY BLOCK----- +`) +} diff --git a/pkg/commands/logging/digitalocean/digitalocean_test.go b/pkg/commands/logging/digitalocean/digitalocean_test.go new file mode 100644 index 000000000..f0952eae2 --- /dev/null +++ b/pkg/commands/logging/digitalocean/digitalocean_test.go @@ -0,0 +1,378 @@ +package digitalocean_test + +import ( + "bytes" + "testing" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/logging/digitalocean" + "github.com/fastly/cli/pkg/config" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" +) + +func TestCreateDigitalOceanInput(t *testing.T) { + for _, testcase := range []struct { + name string + cmd *digitalocean.CreateCommand + want *fastly.CreateDigitalOceanInput + wantError string + }{ + { + name: "required values set flag serviceID", + cmd: createCommandRequired(), + want: &fastly.CreateDigitalOceanInput{ + ServiceID: "123", + ServiceVersion: 4, + Name: fastly.ToPointer("log"), + BucketName: fastly.ToPointer("bucket"), + AccessKey: fastly.ToPointer("access"), + SecretKey: fastly.ToPointer("secret"), + }, + }, + { + name: "all values set flag serviceID", + cmd: createCommandAll(), + want: &fastly.CreateDigitalOceanInput{ + ServiceID: "123", + ServiceVersion: 4, + Name: fastly.ToPointer("log"), + BucketName: fastly.ToPointer("bucket"), + Domain: fastly.ToPointer("nyc3.digitaloceanspaces.com"), + AccessKey: fastly.ToPointer("access"), + SecretKey: fastly.ToPointer("secret"), + Path: fastly.ToPointer("/log"), + Period: fastly.ToPointer(3600), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + MessageType: fastly.ToPointer("classic"), + FormatVersion: fastly.ToPointer(2), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + TimestampFormat: fastly.ToPointer("%Y-%m-%dT%H:%M:%S.000"), + Placement: fastly.ToPointer("none"), + PublicKey: fastly.ToPointer(pgpPublicKey()), + CompressionCodec: fastly.ToPointer("zstd"), + }, + }, + { + name: "error missing serviceID", + cmd: createCommandMissingServiceID(), + want: nil, + wantError: errors.ErrNoServiceID.Error(), + }, + } { + t.Run(testcase.name, func(t *testing.T) { + var bs []byte + out := bytes.NewBuffer(bs) + verboseMode := true + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + AutoCloneFlag: testcase.cmd.AutoClone, + APIClient: testcase.cmd.Globals.APIClient, + Manifest: testcase.cmd.Manifest, + Out: out, + ServiceVersionFlag: testcase.cmd.ServiceVersion, + VerboseMode: verboseMode, + }) + + switch { + case err != nil && testcase.wantError == "": + t.Fatalf("unexpected error getting service details: %v", err) + return + case err != nil && testcase.wantError != "": + testutil.AssertErrorContains(t, err, testcase.wantError) + return + case err == nil && testcase.wantError != "": + t.Fatalf("expected error, have nil (service details: %s, %d)", serviceID, serviceVersion.Number) + case err == nil && testcase.wantError == "": + have, err := testcase.cmd.ConstructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertEqual(t, testcase.want, have) + } + }) + } +} + +func TestUpdateDigitalOceanInput(t *testing.T) { + scenarios := []struct { + name string + cmd *digitalocean.UpdateCommand + api mock.API + want *fastly.UpdateDigitalOceanInput + wantError string + }{ + { + name: "all values set flag serviceID", + cmd: updateCommandAll(), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + GetDigitalOceanFn: getDigitalOceanOK, + }, + want: &fastly.UpdateDigitalOceanInput{ + ServiceID: "123", + ServiceVersion: 4, + Name: "log", + NewName: fastly.ToPointer("new1"), + BucketName: fastly.ToPointer("new2"), + Domain: fastly.ToPointer("new3"), + AccessKey: fastly.ToPointer("new4"), + SecretKey: fastly.ToPointer("new5"), + Path: fastly.ToPointer("new6"), + Period: fastly.ToPointer(3601), + GzipLevel: fastly.ToPointer(0), + Format: fastly.ToPointer("new7"), + FormatVersion: fastly.ToPointer(3), + ResponseCondition: fastly.ToPointer("new8"), + MessageType: fastly.ToPointer("new9"), + TimestampFormat: fastly.ToPointer("new10"), + Placement: fastly.ToPointer("new11"), + PublicKey: fastly.ToPointer("new12"), + CompressionCodec: fastly.ToPointer("new13"), + }, + }, + { + name: "no updates", + cmd: updateCommandNoUpdates(), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + GetDigitalOceanFn: getDigitalOceanOK, + }, + want: &fastly.UpdateDigitalOceanInput{ + ServiceID: "123", + ServiceVersion: 4, + Name: "log", + }, + }, + { + name: "error missing serviceID", + cmd: updateCommandMissingServiceID(), + want: nil, + wantError: errors.ErrNoServiceID.Error(), + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(testcase.name, func(t *testing.T) { + testcase.cmd.Globals.APIClient = testcase.api + + var bs []byte + out := bytes.NewBuffer(bs) + verboseMode := true + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + AutoCloneFlag: testcase.cmd.AutoClone, + APIClient: testcase.api, + Manifest: testcase.cmd.Manifest, + Out: out, + ServiceVersionFlag: testcase.cmd.ServiceVersion, + VerboseMode: verboseMode, + }) + + switch { + case err != nil && testcase.wantError == "": + t.Fatalf("unexpected error getting service details: %v", err) + return + case err != nil && testcase.wantError != "": + testutil.AssertErrorContains(t, err, testcase.wantError) + return + case err == nil && testcase.wantError != "": + t.Fatalf("expected error, have nil (service details: %s, %d)", serviceID, serviceVersion.Number) + case err == nil && testcase.wantError == "": + have, err := testcase.cmd.ConstructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertEqual(t, testcase.want, have) + } + }) + } +} + +func createCommandRequired() *digitalocean.CreateCommand { + var b bytes.Buffer + + g := global.Data{ + Config: config.File{}, + Env: config.Environment{}, + Output: &b, + } + g.APIClient, _ = mock.APIClient(mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + })("token", "endpoint", false) + + return &digitalocean.CreateCommand{ + Base: argparser.Base{ + Globals: &g, + }, + Manifest: manifest.Data{ + Flag: manifest.Flag{ + ServiceID: "123", + }, + }, + ServiceVersion: argparser.OptionalServiceVersion{ + OptionalString: argparser.OptionalString{Value: "1"}, + }, + AutoClone: argparser.OptionalAutoClone{ + OptionalBool: argparser.OptionalBool{ + Optional: argparser.Optional{ + WasSet: true, + }, + Value: true, + }, + }, + EndpointName: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "log"}, + BucketName: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "bucket"}, + AccessKey: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "access"}, + SecretKey: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "secret"}, + } +} + +func createCommandAll() *digitalocean.CreateCommand { + var b bytes.Buffer + + g := global.Data{ + Config: config.File{}, + Env: config.Environment{}, + Output: &b, + } + g.APIClient, _ = mock.APIClient(mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + })("token", "endpoint", false) + + return &digitalocean.CreateCommand{ + Base: argparser.Base{ + Globals: &g, + }, + Manifest: manifest.Data{ + Flag: manifest.Flag{ + ServiceID: "123", + }, + }, + ServiceVersion: argparser.OptionalServiceVersion{ + OptionalString: argparser.OptionalString{Value: "1"}, + }, + AutoClone: argparser.OptionalAutoClone{ + OptionalBool: argparser.OptionalBool{ + Optional: argparser.Optional{ + WasSet: true, + }, + Value: true, + }, + }, + EndpointName: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "log"}, + BucketName: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "bucket"}, + AccessKey: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "access"}, + SecretKey: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "secret"}, + Domain: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "nyc3.digitaloceanspaces.com"}, + Path: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "/log"}, + Period: argparser.OptionalInt{Optional: argparser.Optional{WasSet: true}, Value: 3600}, + Format: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: `%h %l %u %t "%r" %>s %b`}, + FormatVersion: argparser.OptionalInt{Optional: argparser.Optional{WasSet: true}, Value: 2}, + ResponseCondition: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "Prevent default logging"}, + TimestampFormat: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "%Y-%m-%dT%H:%M:%S.000"}, + Placement: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "none"}, + MessageType: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "classic"}, + PublicKey: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: pgpPublicKey()}, + CompressionCodec: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "zstd"}, + } +} + +func createCommandMissingServiceID() *digitalocean.CreateCommand { + res := createCommandAll() + res.Manifest = manifest.Data{} + return res +} + +func updateCommandNoUpdates() *digitalocean.UpdateCommand { + var b bytes.Buffer + + g := global.Data{ + Config: config.File{}, + Env: config.Environment{}, + Output: &b, + } + + return &digitalocean.UpdateCommand{ + Base: argparser.Base{ + Globals: &g, + }, + Manifest: manifest.Data{ + Flag: manifest.Flag{ + ServiceID: "123", + }, + }, + EndpointName: "log", + ServiceVersion: argparser.OptionalServiceVersion{ + OptionalString: argparser.OptionalString{Value: "1"}, + }, + AutoClone: argparser.OptionalAutoClone{ + OptionalBool: argparser.OptionalBool{ + Optional: argparser.Optional{ + WasSet: true, + }, + Value: true, + }, + }, + } +} + +func updateCommandAll() *digitalocean.UpdateCommand { + var b bytes.Buffer + + g := global.Data{ + Config: config.File{}, + Env: config.Environment{}, + Output: &b, + } + + return &digitalocean.UpdateCommand{ + Base: argparser.Base{ + Globals: &g, + }, + Manifest: manifest.Data{ + Flag: manifest.Flag{ + ServiceID: "123", + }, + }, + EndpointName: "log", + ServiceVersion: argparser.OptionalServiceVersion{ + OptionalString: argparser.OptionalString{Value: "1"}, + }, + AutoClone: argparser.OptionalAutoClone{ + OptionalBool: argparser.OptionalBool{ + Optional: argparser.Optional{ + WasSet: true, + }, + Value: true, + }, + }, + NewName: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new1"}, + BucketName: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new2"}, + Domain: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new3"}, + AccessKey: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new4"}, + SecretKey: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new5"}, + Path: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new6"}, + Period: argparser.OptionalInt{Optional: argparser.Optional{WasSet: true}, Value: 3601}, + GzipLevel: argparser.OptionalInt{Optional: argparser.Optional{WasSet: true}, Value: 0}, + Format: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new7"}, + FormatVersion: argparser.OptionalInt{Optional: argparser.Optional{WasSet: true}, Value: 3}, + ResponseCondition: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new8"}, + MessageType: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new9"}, + TimestampFormat: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new10"}, + Placement: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new11"}, + PublicKey: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new12"}, + CompressionCodec: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new13"}, + } +} + +func updateCommandMissingServiceID() *digitalocean.UpdateCommand { + res := updateCommandAll() + res.Manifest = manifest.Data{} + return res +} diff --git a/pkg/logging/digitalocean/doc.go b/pkg/commands/logging/digitalocean/doc.go similarity index 100% rename from pkg/logging/digitalocean/doc.go rename to pkg/commands/logging/digitalocean/doc.go diff --git a/pkg/commands/logging/digitalocean/list.go b/pkg/commands/logging/digitalocean/list.go new file mode 100644 index 000000000..959b33cc8 --- /dev/null +++ b/pkg/commands/logging/digitalocean/list.go @@ -0,0 +1,132 @@ +package digitalocean + +import ( + "fmt" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// ListCommand calls the Fastly API to list DigitalOcean Spaces logging endpoints. +type ListCommand struct { + argparser.Base + argparser.JSONOutput + + Input fastly.ListDigitalOceansInput + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion +} + +// NewListCommand returns a usable command registered under the parent. +func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { + c := ListCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("list", "List DigitalOcean Spaces logging endpoints on a Fastly service version") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + return &c +} + +// Exec invokes the application logic for the command. +func (c *ListCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fsterr.ServiceVersion(serviceVersion), + }) + return err + } + + c.Input.ServiceID = serviceID + c.Input.ServiceVersion = fastly.ToValue(serviceVersion.Number) + + o, err := c.Globals.APIClient.ListDigitalOceans(&c.Input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + if !c.Globals.Verbose() { + tw := text.NewTable(out) + tw.AddHeader("SERVICE", "VERSION", "NAME") + for _, digitalocean := range o { + tw.AddLine( + fastly.ToValue(digitalocean.ServiceID), + fastly.ToValue(digitalocean.ServiceVersion), + fastly.ToValue(digitalocean.Name), + ) + } + tw.Print() + return nil + } + + fmt.Fprintf(out, "Version: %d\n", c.Input.ServiceVersion) + for i, digitalocean := range o { + fmt.Fprintf(out, "\tDigitalOcean %d/%d\n", i+1, len(o)) + fmt.Fprintf(out, "\t\tService ID: %s\n", fastly.ToValue(digitalocean.ServiceID)) + fmt.Fprintf(out, "\t\tVersion: %d\n", fastly.ToValue(digitalocean.ServiceVersion)) + fmt.Fprintf(out, "\t\tName: %s\n", fastly.ToValue(digitalocean.Name)) + fmt.Fprintf(out, "\t\tBucket: %s\n", fastly.ToValue(digitalocean.BucketName)) + fmt.Fprintf(out, "\t\tDomain: %s\n", fastly.ToValue(digitalocean.Domain)) + fmt.Fprintf(out, "\t\tAccess key: %s\n", fastly.ToValue(digitalocean.AccessKey)) + fmt.Fprintf(out, "\t\tSecret key: %s\n", fastly.ToValue(digitalocean.SecretKey)) + fmt.Fprintf(out, "\t\tPath: %s\n", fastly.ToValue(digitalocean.Path)) + fmt.Fprintf(out, "\t\tPeriod: %d\n", fastly.ToValue(digitalocean.Period)) + fmt.Fprintf(out, "\t\tGZip level: %d\n", fastly.ToValue(digitalocean.GzipLevel)) + fmt.Fprintf(out, "\t\tFormat: %s\n", fastly.ToValue(digitalocean.Format)) + fmt.Fprintf(out, "\t\tFormat version: %d\n", fastly.ToValue(digitalocean.FormatVersion)) + fmt.Fprintf(out, "\t\tResponse condition: %s\n", fastly.ToValue(digitalocean.ResponseCondition)) + fmt.Fprintf(out, "\t\tMessage type: %s\n", fastly.ToValue(digitalocean.MessageType)) + fmt.Fprintf(out, "\t\tTimestamp format: %s\n", fastly.ToValue(digitalocean.TimestampFormat)) + fmt.Fprintf(out, "\t\tPlacement: %s\n", fastly.ToValue(digitalocean.Placement)) + fmt.Fprintf(out, "\t\tPublic key: %s\n", fastly.ToValue(digitalocean.PublicKey)) + } + fmt.Fprintln(out) + + return nil +} diff --git a/pkg/commands/logging/digitalocean/root.go b/pkg/commands/logging/digitalocean/root.go new file mode 100644 index 000000000..0beb5fc58 --- /dev/null +++ b/pkg/commands/logging/digitalocean/root.go @@ -0,0 +1,31 @@ +package digitalocean + +import ( + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" +) + +// RootCommand is the parent command for all subcommands in this package. +// It should be installed under the primary root command. +type RootCommand struct { + argparser.Base + // no flags +} + +// CommandName is the string to be used to invoke this command. +const CommandName = "digitalocean" + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent argparser.Registerer, g *global.Data) *RootCommand { + var c RootCommand + c.Globals = g + c.CmdClause = parent.Command(CommandName, "Manipulate Fastly service version DigitalOcean Spaces logging endpoints") + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { + panic("unreachable") +} diff --git a/pkg/commands/logging/digitalocean/update.go b/pkg/commands/logging/digitalocean/update.go new file mode 100644 index 000000000..1d36241ae --- /dev/null +++ b/pkg/commands/logging/digitalocean/update.go @@ -0,0 +1,218 @@ +package digitalocean + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/logging/common" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/text" +) + +// UpdateCommand calls the Fastly API to update a DigitalOcean Spaces logging endpoint. +type UpdateCommand struct { + argparser.Base + Manifest manifest.Data + + // Required. + EndpointName string + ServiceName argparser.OptionalServiceNameID + ServiceVersion argparser.OptionalServiceVersion + + // Optional. + AutoClone argparser.OptionalAutoClone + NewName argparser.OptionalString + BucketName argparser.OptionalString + Domain argparser.OptionalString + AccessKey argparser.OptionalString + SecretKey argparser.OptionalString + Path argparser.OptionalString + Period argparser.OptionalInt + GzipLevel argparser.OptionalInt + Format argparser.OptionalString + FormatVersion argparser.OptionalInt + ResponseCondition argparser.OptionalString + MessageType argparser.OptionalString + TimestampFormat argparser.OptionalString + Placement argparser.OptionalString + PublicKey argparser.OptionalString + CompressionCodec argparser.OptionalString +} + +// NewUpdateCommand returns a usable command registered under the parent. +func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { + c := UpdateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("update", "Update a DigitalOcean Spaces logging endpoint on a Fastly service version") + + // Required. + c.CmdClause.Flag("name", "The name of the DigitalOcean Spaces logging object").Short('n').Required().StringVar(&c.EndpointName) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.ServiceVersion.Value, + Required: true, + }) + + // Optional. + c.CmdClause.Flag("access-key", "Your DigitalOcean Spaces account access key").Action(c.AccessKey.Set).StringVar(&c.AccessKey.Value) + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.AutoClone.Set, + Dst: &c.AutoClone.Value, + }) + c.CmdClause.Flag("bucket", "The name of the DigitalOcean Space").Action(c.BucketName.Set).StringVar(&c.BucketName.Value) + common.CompressionCodec(c.CmdClause, &c.CompressionCodec) + c.CmdClause.Flag("domain", "The domain of the DigitalOcean Spaces endpoint (default 'nyc3.digitaloceanspaces.com')").Action(c.Domain.Set).StringVar(&c.Domain.Value) + common.Format(c.CmdClause, &c.Format) + common.FormatVersion(c.CmdClause, &c.FormatVersion) + common.GzipLevel(c.CmdClause, &c.GzipLevel) + common.MessageType(c.CmdClause, &c.MessageType) + c.CmdClause.Flag("new-name", "New name of the DigitalOcean Spaces logging object").Action(c.NewName.Set).StringVar(&c.NewName.Value) + common.Path(c.CmdClause, &c.Path) + common.Period(c.CmdClause, &c.Period) + common.Placement(c.CmdClause, &c.Placement) + common.PublicKey(c.CmdClause, &c.PublicKey) + common.ResponseCondition(c.CmdClause, &c.ResponseCondition) + c.CmdClause.Flag("secret-key", "Your DigitalOcean Spaces account secret key").Action(c.SecretKey.Set).StringVar(&c.SecretKey.Value) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.ServiceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.ServiceName.Value, + }) + common.TimestampFormat(c.CmdClause, &c.TimestampFormat) + return &c +} + +// ConstructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *UpdateCommand) ConstructInput(serviceID string, serviceVersion int) (*fastly.UpdateDigitalOceanInput, error) { + input := fastly.UpdateDigitalOceanInput{ + ServiceID: serviceID, + ServiceVersion: serviceVersion, + Name: c.EndpointName, + } + + // Set new values if set by user. + if c.NewName.WasSet { + input.NewName = &c.NewName.Value + } + + if c.BucketName.WasSet { + input.BucketName = &c.BucketName.Value + } + + if c.Domain.WasSet { + input.Domain = &c.Domain.Value + } + + if c.AccessKey.WasSet { + input.AccessKey = &c.AccessKey.Value + } + + if c.SecretKey.WasSet { + input.SecretKey = &c.SecretKey.Value + } + + if c.Path.WasSet { + input.Path = &c.Path.Value + } + + if c.Period.WasSet { + input.Period = &c.Period.Value + } + + if c.GzipLevel.WasSet { + input.GzipLevel = &c.GzipLevel.Value + } + + if c.Format.WasSet { + input.Format = fastly.ToPointer(argparser.Content(c.Format.Value)) + } + + if c.FormatVersion.WasSet { + input.FormatVersion = &c.FormatVersion.Value + } + + if c.ResponseCondition.WasSet { + input.ResponseCondition = &c.ResponseCondition.Value + } + + if c.MessageType.WasSet { + input.MessageType = &c.MessageType.Value + } + + if c.TimestampFormat.WasSet { + input.TimestampFormat = &c.TimestampFormat.Value + } + + if c.Placement.WasSet { + input.Placement = &c.Placement.Value + } + + if c.PublicKey.WasSet { + input.PublicKey = &c.PublicKey.Value + } + + if c.CompressionCodec.WasSet { + input.CompressionCodec = &c.CompressionCodec.Value + } + + return &input, nil +} + +// Exec invokes the application logic for the command. +func (c *UpdateCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.AutoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.ServiceName, + ServiceVersionFlag: c.ServiceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + input, err := c.ConstructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + digitalocean, err := c.Globals.APIClient.UpdateDigitalOcean(input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + text.Success(out, + "Updated DigitalOcean Spaces logging endpoint %s (service %s version %d)", + fastly.ToValue(digitalocean.Name), + fastly.ToValue(digitalocean.ServiceID), + fastly.ToValue(digitalocean.ServiceVersion), + ) + return nil +} diff --git a/pkg/logging/doc.go b/pkg/commands/logging/doc.go similarity index 100% rename from pkg/logging/doc.go rename to pkg/commands/logging/doc.go diff --git a/pkg/commands/logging/elasticsearch/create.go b/pkg/commands/logging/elasticsearch/create.go new file mode 100644 index 000000000..d036207a6 --- /dev/null +++ b/pkg/commands/logging/elasticsearch/create.go @@ -0,0 +1,209 @@ +package elasticsearch + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/logging/common" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/text" +) + +// CreateCommand calls the Fastly API to create an Elasticsearch logging endpoint. +type CreateCommand struct { + argparser.Base + Manifest manifest.Data + + // Required. + ServiceName argparser.OptionalServiceNameID + ServiceVersion argparser.OptionalServiceVersion + + // Optional. + AutoClone argparser.OptionalAutoClone + EndpointName argparser.OptionalString // Can't shadow argparser.Base method Name(). + Format argparser.OptionalString + FormatVersion argparser.OptionalInt + Index argparser.OptionalString + Password argparser.OptionalString + Pipeline argparser.OptionalString + Placement argparser.OptionalString + RequestMaxBytes argparser.OptionalInt + RequestMaxEntries argparser.OptionalInt + ResponseCondition argparser.OptionalString + TLSCACert argparser.OptionalString + TLSClientCert argparser.OptionalString + TLSClientKey argparser.OptionalString + TLSHostname argparser.OptionalString + URL argparser.OptionalString + User argparser.OptionalString +} + +// NewCreateCommand returns a usable command registered under the parent. +func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { + c := CreateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("create", "Create an Elasticsearch logging endpoint on a Fastly service version").Alias("add") + + // Required. + c.CmdClause.Flag("name", "The name of the Elasticsearch logging object. Used as a primary key for API access").Short('n').Action(c.EndpointName.Set).StringVar(&c.EndpointName.Value) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.ServiceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.AutoClone.Set, + Dst: &c.AutoClone.Value, + }) + common.Format(c.CmdClause, &c.Format) + common.FormatVersion(c.CmdClause, &c.FormatVersion) + c.CmdClause.Flag("index", `The name of the Elasticsearch index to send documents (logs) to. The index must follow the Elasticsearch index format rules (https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-create-index.html). We support strftime (http://man7.org/linux/man-pages/man3/strftime.3.html) interpolated variables inside braces prefixed with a pound symbol. For example, #{%F} will interpolate as YYYY-MM-DD with today's date`).Action(c.Index.Set).StringVar(&c.Index.Value) + common.Placement(c.CmdClause, &c.Placement) + c.CmdClause.Flag("pipeline", "The ID of the Elasticsearch ingest pipeline to apply pre-process transformations to before indexing. For example my_pipeline_id. Learn more about creating a pipeline in the Elasticsearch docs (https://www.elastic.co/guide/en/elasticsearch/reference/current/ingest.html)").Action(c.Password.Set).StringVar(&c.Pipeline.Value) + c.CmdClause.Flag("request-max-bytes", "Maximum size of log batch, if non-zero. Defaults to 100MB").Action(c.RequestMaxBytes.Set).IntVar(&c.RequestMaxBytes.Value) + c.CmdClause.Flag("request-max-entries", "Maximum number of logs to append to a batch, if non-zero. Defaults to 10k").Action(c.RequestMaxEntries.Set).IntVar(&c.RequestMaxEntries.Value) + common.ResponseCondition(c.CmdClause, &c.ResponseCondition) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.ServiceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.ServiceName.Value, + }) + common.TLSCACert(c.CmdClause, &c.TLSCACert) + common.TLSClientCert(c.CmdClause, &c.TLSClientCert) + common.TLSClientKey(c.CmdClause, &c.TLSClientKey) + common.TLSHostname(c.CmdClause, &c.TLSHostname) + c.CmdClause.Flag("url", "The URL to stream logs to. Must use HTTPS.").Action(c.URL.Set).StringVar(&c.URL.Value) + return &c +} + +// ConstructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *CreateCommand) ConstructInput(serviceID string, serviceVersion int) (*fastly.CreateElasticsearchInput, error) { + var input fastly.CreateElasticsearchInput + + input.ServiceID = serviceID + input.ServiceVersion = serviceVersion + if c.EndpointName.WasSet { + input.Name = &c.EndpointName.Value + } + if c.Index.WasSet { + input.Index = &c.Index.Value + } + if c.URL.WasSet { + input.URL = &c.URL.Value + } + + if c.Pipeline.WasSet { + input.Pipeline = &c.Pipeline.Value + } + + if c.RequestMaxEntries.WasSet { + input.RequestMaxEntries = &c.RequestMaxEntries.Value + } + + if c.RequestMaxBytes.WasSet { + input.RequestMaxBytes = &c.RequestMaxBytes.Value + } + + if c.User.WasSet { + input.User = &c.User.Value + } + + if c.Password.WasSet { + input.Password = &c.Password.Value + } + + if c.TLSCACert.WasSet { + input.TLSCACert = &c.TLSCACert.Value + } + + if c.TLSClientCert.WasSet { + input.TLSClientCert = &c.TLSClientCert.Value + } + + if c.TLSClientKey.WasSet { + input.TLSClientKey = &c.TLSClientKey.Value + } + + if c.TLSHostname.WasSet { + input.TLSHostname = &c.TLSHostname.Value + } + + if c.Format.WasSet { + input.Format = fastly.ToPointer(argparser.Content(c.Format.Value)) + } + + if c.FormatVersion.WasSet { + input.FormatVersion = &c.FormatVersion.Value + } + + if c.ResponseCondition.WasSet { + input.ResponseCondition = &c.ResponseCondition.Value + } + + if c.Placement.WasSet { + input.Placement = &c.Placement.Value + } + + return &input, nil +} + +// Exec invokes the application logic for the command. +func (c *CreateCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.AutoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.ServiceName, + ServiceVersionFlag: c.ServiceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + input, err := c.ConstructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + d, err := c.Globals.APIClient.CreateElasticsearch(input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + text.Success(out, + "Created Elasticsearch logging endpoint %s (service %s version %d)", + fastly.ToValue(d.Name), + fastly.ToValue(d.ServiceID), + fastly.ToValue(d.ServiceVersion), + ) + return nil +} diff --git a/pkg/commands/logging/elasticsearch/delete.go b/pkg/commands/logging/elasticsearch/delete.go new file mode 100644 index 000000000..8f308108d --- /dev/null +++ b/pkg/commands/logging/elasticsearch/delete.go @@ -0,0 +1,94 @@ +package elasticsearch + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// DeleteCommand calls the Fastly API to delete an Elasticsearch logging endpoint. +type DeleteCommand struct { + argparser.Base + Input fastly.DeleteElasticsearchInput + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion + autoClone argparser.OptionalAutoClone +} + +// NewDeleteCommand returns a usable command registered under the parent. +func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { + c := DeleteCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("delete", "Delete an Elasticsearch logging endpoint on a Fastly service version").Alias("remove") + + // Required. + c.CmdClause.Flag("name", "The name of the Elasticsearch logging object").Short('n').Required().StringVar(&c.Input.Name) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.autoClone.Set, + Dst: &c.autoClone.Value, + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + return &c +} + +// Exec invokes the application logic for the command. +func (c *DeleteCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.autoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + c.Input.ServiceID = serviceID + c.Input.ServiceVersion = fastly.ToValue(serviceVersion.Number) + + if err := c.Globals.APIClient.DeleteElasticsearch(&c.Input); err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + text.Success(out, "Deleted Elasticsearch logging endpoint %s (service %s version %d)", c.Input.Name, c.Input.ServiceID, c.Input.ServiceVersion) + return nil +} diff --git a/pkg/commands/logging/elasticsearch/describe.go b/pkg/commands/logging/elasticsearch/describe.go new file mode 100644 index 000000000..4253ecec2 --- /dev/null +++ b/pkg/commands/logging/elasticsearch/describe.go @@ -0,0 +1,117 @@ +package elasticsearch + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// DescribeCommand calls the Fastly API to describe an Elasticsearch logging endpoint. +type DescribeCommand struct { + argparser.Base + argparser.JSONOutput + + Input fastly.GetElasticsearchInput + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion +} + +// NewDescribeCommand returns a usable command registered under the parent. +func NewDescribeCommand(parent argparser.Registerer, g *global.Data) *DescribeCommand { + c := DescribeCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("describe", "Show detailed information about an Elasticsearch logging endpoint on a Fastly service version").Alias("get") + + // Required. + c.CmdClause.Flag("name", "The name of the Elasticsearch logging object").Short('n').Required().StringVar(&c.Input.Name) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + return &c +} + +// Exec invokes the application logic for the command. +func (c *DescribeCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fsterr.ServiceVersion(serviceVersion), + }) + return err + } + + c.Input.ServiceID = serviceID + c.Input.ServiceVersion = fastly.ToValue(serviceVersion.Number) + + o, err := c.Globals.APIClient.GetElasticsearch(&c.Input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + lines := text.Lines{ + "Format version": fastly.ToValue(o.FormatVersion), + "Format": fastly.ToValue(o.Format), + "Index": fastly.ToValue(o.Index), + "Name": fastly.ToValue(o.Name), + "Password": fastly.ToValue(o.Password), + "Pipeline": fastly.ToValue(o.Pipeline), + "Placement": fastly.ToValue(o.Placement), + "Response condition": fastly.ToValue(o.ResponseCondition), + "TLS CA certificate": fastly.ToValue(o.TLSCACert), + "TLS client certificate": fastly.ToValue(o.TLSClientCert), + "TLS client key": fastly.ToValue(o.TLSClientKey), + "TLS hostname": fastly.ToValue(o.TLSHostname), + "URL": fastly.ToValue(o.URL), + "User": fastly.ToValue(o.User), + "Version": fastly.ToValue(o.ServiceVersion), + } + if !c.Globals.Verbose() { + lines["Service ID"] = fastly.ToValue(o.ServiceID) + } + text.PrintLines(out, lines) + + return nil +} diff --git a/pkg/logging/elasticsearch/doc.go b/pkg/commands/logging/elasticsearch/doc.go similarity index 100% rename from pkg/logging/elasticsearch/doc.go rename to pkg/commands/logging/elasticsearch/doc.go diff --git a/pkg/commands/logging/elasticsearch/elasticsearch_integration_test.go b/pkg/commands/logging/elasticsearch/elasticsearch_integration_test.go new file mode 100644 index 000000000..58960a8ef --- /dev/null +++ b/pkg/commands/logging/elasticsearch/elasticsearch_integration_test.go @@ -0,0 +1,479 @@ +package elasticsearch_test + +import ( + "bytes" + "errors" + "io" + "strings" + "testing" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/app" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" +) + +func TestElasticsearchCreate(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging elasticsearch create --service-id 123 --version 1 --name log --index logs --url example.com --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + CreateElasticsearchFn: createElasticsearchOK, + }, + wantOutput: "Created Elasticsearch logging endpoint log (service 123 version 4)", + }, + { + args: args("logging elasticsearch create --service-id 123 --version 1 --name log --index logs --url example.com --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + CreateElasticsearchFn: createElasticsearchError, + }, + wantError: errTest.Error(), + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertStringContains(t, stdout.String(), testcase.wantOutput) + }) + } +} + +func TestElasticsearchList(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging elasticsearch list --service-id 123 --version 1"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListElasticsearchFn: listElasticsearchsOK, + }, + wantOutput: listElasticsearchsShortOutput, + }, + { + args: args("logging elasticsearch list --service-id 123 --version 1 --verbose"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListElasticsearchFn: listElasticsearchsOK, + }, + wantOutput: listElasticsearchsVerboseOutput, + }, + { + args: args("logging elasticsearch list --service-id 123 --version 1 -v"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListElasticsearchFn: listElasticsearchsOK, + }, + wantOutput: listElasticsearchsVerboseOutput, + }, + { + args: args("logging elasticsearch --verbose list --service-id 123 --version 1"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListElasticsearchFn: listElasticsearchsOK, + }, + wantOutput: listElasticsearchsVerboseOutput, + }, + { + args: args("logging -v elasticsearch list --service-id 123 --version 1"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListElasticsearchFn: listElasticsearchsOK, + }, + wantOutput: listElasticsearchsVerboseOutput, + }, + { + args: args("logging elasticsearch list --service-id 123 --version 1"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListElasticsearchFn: listElasticsearchsError, + }, + wantError: errTest.Error(), + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertString(t, testcase.wantOutput, stdout.String()) + }) + } +} + +func TestElasticsearchDescribe(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging elasticsearch describe --service-id 123 --version 1"), + wantError: "error parsing arguments: required flag --name not provided", + }, + { + args: args("logging elasticsearch describe --service-id 123 --version 1 --name logs"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + GetElasticsearchFn: getElasticsearchError, + }, + wantError: errTest.Error(), + }, + { + args: args("logging elasticsearch describe --service-id 123 --version 1 --name logs"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + GetElasticsearchFn: getElasticsearchOK, + }, + wantOutput: describeElasticsearchOutput, + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertString(t, testcase.wantOutput, stdout.String()) + }) + } +} + +func TestElasticsearchUpdate(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging elasticsearch update --service-id 123 --version 1 --new-name log"), + wantError: "error parsing arguments: required flag --name not provided", + }, + { + args: args("logging elasticsearch update --service-id 123 --version 1 --name logs --new-name log --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + UpdateElasticsearchFn: updateElasticsearchError, + }, + wantError: errTest.Error(), + }, + { + args: args("logging elasticsearch update --service-id 123 --version 1 --name logs --new-name log --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + UpdateElasticsearchFn: updateElasticsearchOK, + }, + wantOutput: "Updated Elasticsearch logging endpoint log (service 123 version 4)", + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertStringContains(t, stdout.String(), testcase.wantOutput) + }) + } +} + +func TestElasticsearchDelete(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging elasticsearch delete --service-id 123 --version 1"), + wantError: "error parsing arguments: required flag --name not provided", + }, + { + args: args("logging elasticsearch delete --service-id 123 --version 1 --name logs --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + DeleteElasticsearchFn: deleteElasticsearchError, + }, + wantError: errTest.Error(), + }, + { + args: args("logging elasticsearch delete --service-id 123 --version 1 --name logs --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + DeleteElasticsearchFn: deleteElasticsearchOK, + }, + wantOutput: "Deleted Elasticsearch logging endpoint logs (service 123 version 4)", + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertStringContains(t, stdout.String(), testcase.wantOutput) + }) + } +} + +var errTest = errors.New("fixture error") + +func createElasticsearchOK(i *fastly.CreateElasticsearchInput) (*fastly.Elasticsearch, error) { + return &fastly.Elasticsearch{ + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("log"), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + Index: fastly.ToPointer("logs"), + URL: fastly.ToPointer("example.com"), + Pipeline: fastly.ToPointer("logs"), + User: fastly.ToPointer("user"), + Password: fastly.ToPointer("password"), + RequestMaxEntries: fastly.ToPointer(2), + RequestMaxBytes: fastly.ToPointer(2), + Placement: fastly.ToPointer("none"), + TLSCACert: fastly.ToPointer("-----BEGIN CERTIFICATE-----foo"), + TLSHostname: fastly.ToPointer("example.com"), + TLSClientCert: fastly.ToPointer("-----BEGIN CERTIFICATE-----bar"), + TLSClientKey: fastly.ToPointer("-----BEGIN PRIVATE KEY-----bar"), + FormatVersion: fastly.ToPointer(2), + }, nil +} + +func createElasticsearchError(_ *fastly.CreateElasticsearchInput) (*fastly.Elasticsearch, error) { + return nil, errTest +} + +func listElasticsearchsOK(i *fastly.ListElasticsearchInput) ([]*fastly.Elasticsearch, error) { + return []*fastly.Elasticsearch{ + { + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("logs"), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + Index: fastly.ToPointer("logs"), + URL: fastly.ToPointer("example.com"), + Pipeline: fastly.ToPointer("logs"), + User: fastly.ToPointer("user"), + Password: fastly.ToPointer("password"), + RequestMaxEntries: fastly.ToPointer(2), + RequestMaxBytes: fastly.ToPointer(2), + Placement: fastly.ToPointer("none"), + TLSCACert: fastly.ToPointer("-----BEGIN CERTIFICATE-----foo"), + TLSHostname: fastly.ToPointer("example.com"), + TLSClientCert: fastly.ToPointer("-----BEGIN CERTIFICATE-----bar"), + TLSClientKey: fastly.ToPointer("-----BEGIN PRIVATE KEY-----bar"), + FormatVersion: fastly.ToPointer(2), + }, + { + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("analytics"), + Index: fastly.ToPointer("analytics"), + URL: fastly.ToPointer("example.com"), + Pipeline: fastly.ToPointer("analytics"), + User: fastly.ToPointer("user"), + Password: fastly.ToPointer("password"), + RequestMaxEntries: fastly.ToPointer(2), + RequestMaxBytes: fastly.ToPointer(2), + Placement: fastly.ToPointer("none"), + TLSCACert: fastly.ToPointer("-----BEGIN CERTIFICATE-----foo"), + TLSHostname: fastly.ToPointer("example.com"), + TLSClientCert: fastly.ToPointer("-----BEGIN CERTIFICATE-----bar"), + TLSClientKey: fastly.ToPointer("-----BEGIN PRIVATE KEY-----bar"), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + FormatVersion: fastly.ToPointer(2), + }, + }, nil +} + +func listElasticsearchsError(_ *fastly.ListElasticsearchInput) ([]*fastly.Elasticsearch, error) { + return nil, errTest +} + +var listElasticsearchsShortOutput = strings.TrimSpace(` +SERVICE VERSION NAME +123 1 logs +123 1 analytics +`) + "\n" + +var listElasticsearchsVerboseOutput = strings.TrimSpace(` +Fastly API endpoint: https://api.fastly.com +Fastly API token provided via config file (profile: user) + +Service ID (via --service-id): 123 + +Version: 1 + Elasticsearch 1/2 + Service ID: 123 + Version: 1 + Name: logs + Index: logs + URL: example.com + Pipeline: logs + TLS CA certificate: -----BEGIN CERTIFICATE-----foo + TLS client certificate: -----BEGIN CERTIFICATE-----bar + TLS client key: -----BEGIN PRIVATE KEY-----bar + TLS hostname: example.com + User: user + Password: password + Format: %h %l %u %t "%r" %>s %b + Format version: 2 + Response condition: Prevent default logging + Placement: none + Elasticsearch 2/2 + Service ID: 123 + Version: 1 + Name: analytics + Index: analytics + URL: example.com + Pipeline: analytics + TLS CA certificate: -----BEGIN CERTIFICATE-----foo + TLS client certificate: -----BEGIN CERTIFICATE-----bar + TLS client key: -----BEGIN PRIVATE KEY-----bar + TLS hostname: example.com + User: user + Password: password + Format: %h %l %u %t "%r" %>s %b + Format version: 2 + Response condition: Prevent default logging + Placement: none +`) + "\n\n" + +func getElasticsearchOK(i *fastly.GetElasticsearchInput) (*fastly.Elasticsearch, error) { + return &fastly.Elasticsearch{ + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("logs"), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + Index: fastly.ToPointer("logs"), + URL: fastly.ToPointer("example.com"), + Pipeline: fastly.ToPointer("logs"), + User: fastly.ToPointer("user"), + Password: fastly.ToPointer("password"), + RequestMaxEntries: fastly.ToPointer(2), + RequestMaxBytes: fastly.ToPointer(2), + Placement: fastly.ToPointer("none"), + TLSCACert: fastly.ToPointer("-----BEGIN CERTIFICATE-----foo"), + TLSHostname: fastly.ToPointer("example.com"), + TLSClientCert: fastly.ToPointer("-----BEGIN CERTIFICATE-----bar"), + TLSClientKey: fastly.ToPointer("-----BEGIN PRIVATE KEY-----bar"), + FormatVersion: fastly.ToPointer(2), + }, nil +} + +func getElasticsearchError(_ *fastly.GetElasticsearchInput) (*fastly.Elasticsearch, error) { + return nil, errTest +} + +var describeElasticsearchOutput = "\n" + strings.TrimSpace(` +Format: %h %l %u %t "%r" %>s %b +Format version: 2 +Index: logs +Name: logs +Password: password +Pipeline: logs +Placement: none +Response condition: Prevent default logging +Service ID: 123 +TLS CA certificate: -----BEGIN CERTIFICATE-----foo +TLS client certificate: -----BEGIN CERTIFICATE-----bar +TLS client key: -----BEGIN PRIVATE KEY-----bar +TLS hostname: example.com +URL: example.com +User: user +Version: 1 +`) + "\n" + +func updateElasticsearchOK(i *fastly.UpdateElasticsearchInput) (*fastly.Elasticsearch, error) { + return &fastly.Elasticsearch{ + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("log"), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + Index: fastly.ToPointer("logs"), + URL: fastly.ToPointer("example.com"), + Pipeline: fastly.ToPointer("logs"), + User: fastly.ToPointer("user"), + Password: fastly.ToPointer("password"), + RequestMaxEntries: fastly.ToPointer(2), + RequestMaxBytes: fastly.ToPointer(2), + Placement: fastly.ToPointer("none"), + TLSCACert: fastly.ToPointer("-----BEGIN CERTIFICATE-----foo"), + TLSHostname: fastly.ToPointer("example.com"), + TLSClientCert: fastly.ToPointer("-----BEGIN CERTIFICATE-----bar"), + TLSClientKey: fastly.ToPointer("-----BEGIN PRIVATE KEY-----bar"), + FormatVersion: fastly.ToPointer(2), + }, nil +} + +func updateElasticsearchError(_ *fastly.UpdateElasticsearchInput) (*fastly.Elasticsearch, error) { + return nil, errTest +} + +func deleteElasticsearchOK(_ *fastly.DeleteElasticsearchInput) error { + return nil +} + +func deleteElasticsearchError(_ *fastly.DeleteElasticsearchInput) error { + return errTest +} diff --git a/pkg/commands/logging/elasticsearch/elasticsearch_test.go b/pkg/commands/logging/elasticsearch/elasticsearch_test.go new file mode 100644 index 000000000..6b936ed47 --- /dev/null +++ b/pkg/commands/logging/elasticsearch/elasticsearch_test.go @@ -0,0 +1,378 @@ +package elasticsearch_test + +import ( + "bytes" + "testing" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/logging/elasticsearch" + "github.com/fastly/cli/pkg/config" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" +) + +func TestCreateElasticsearchInput(t *testing.T) { + for _, testcase := range []struct { + name string + cmd *elasticsearch.CreateCommand + want *fastly.CreateElasticsearchInput + wantError string + }{ + { + name: "required values set flag serviceID", + cmd: createCommandRequired(), + want: &fastly.CreateElasticsearchInput{ + ServiceID: "123", + ServiceVersion: 4, + Name: fastly.ToPointer("log"), + Index: fastly.ToPointer("logs"), + URL: fastly.ToPointer("example.com"), + }, + }, + { + name: "all values set flag serviceID", + cmd: createCommandAll(), + want: &fastly.CreateElasticsearchInput{ + ServiceID: "123", + ServiceVersion: 4, + Name: fastly.ToPointer("logs"), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + Index: fastly.ToPointer("logs"), + URL: fastly.ToPointer("example.com"), + Pipeline: fastly.ToPointer("my_pipeline_id"), + User: fastly.ToPointer("user"), + Password: fastly.ToPointer("password"), + RequestMaxEntries: fastly.ToPointer(2), + RequestMaxBytes: fastly.ToPointer(2), + Placement: fastly.ToPointer("none"), + TLSCACert: fastly.ToPointer("-----BEGIN CERTIFICATE-----foo"), + TLSHostname: fastly.ToPointer("example.com"), + TLSClientCert: fastly.ToPointer("-----BEGIN CERTIFICATE-----bar"), + TLSClientKey: fastly.ToPointer("-----BEGIN PRIVATE KEY-----bar"), + FormatVersion: fastly.ToPointer(2), + }, + }, + { + name: "error missing serviceID", + cmd: createCommandMissingServiceID(), + want: nil, + wantError: errors.ErrNoServiceID.Error(), + }, + } { + t.Run(testcase.name, func(t *testing.T) { + var bs []byte + out := bytes.NewBuffer(bs) + verboseMode := true + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + AutoCloneFlag: testcase.cmd.AutoClone, + APIClient: testcase.cmd.Globals.APIClient, + Manifest: testcase.cmd.Manifest, + Out: out, + ServiceVersionFlag: testcase.cmd.ServiceVersion, + VerboseMode: verboseMode, + }) + + switch { + case err != nil && testcase.wantError == "": + t.Fatalf("unexpected error getting service details: %v", err) + return + case err != nil && testcase.wantError != "": + testutil.AssertErrorContains(t, err, testcase.wantError) + return + case err == nil && testcase.wantError != "": + t.Fatalf("expected error, have nil (service details: %s, %d)", serviceID, serviceVersion.Number) + case err == nil && testcase.wantError == "": + have, err := testcase.cmd.ConstructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertEqual(t, testcase.want, have) + } + }) + } +} + +func TestUpdateElasticsearchInput(t *testing.T) { + scenarios := []struct { + name string + cmd *elasticsearch.UpdateCommand + api mock.API + want *fastly.UpdateElasticsearchInput + wantError string + }{ + { + name: "all values set flag serviceID", + cmd: updateCommandAll(), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + GetElasticsearchFn: getElasticsearchOK, + }, + want: &fastly.UpdateElasticsearchInput{ + ServiceID: "123", + ServiceVersion: 4, + Name: "log", + NewName: fastly.ToPointer("new1"), + Index: fastly.ToPointer("new2"), + URL: fastly.ToPointer("new3"), + Pipeline: fastly.ToPointer("new4"), + User: fastly.ToPointer("new5"), + Password: fastly.ToPointer("new6"), + RequestMaxEntries: fastly.ToPointer(3), + RequestMaxBytes: fastly.ToPointer(3), + Placement: fastly.ToPointer("new7"), + Format: fastly.ToPointer("new8"), + FormatVersion: fastly.ToPointer(3), + ResponseCondition: fastly.ToPointer("new9"), + TLSCACert: fastly.ToPointer("new10"), + TLSClientCert: fastly.ToPointer("new11"), + TLSClientKey: fastly.ToPointer("new12"), + TLSHostname: fastly.ToPointer("new13"), + }, + }, + { + name: "no updates", + cmd: updateCommandNoUpdates(), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + GetElasticsearchFn: getElasticsearchOK, + }, + want: &fastly.UpdateElasticsearchInput{ + ServiceID: "123", + ServiceVersion: 4, + Name: "log", + }, + }, + { + name: "error missing serviceID", + cmd: updateCommandMissingServiceID(), + want: nil, + wantError: errors.ErrNoServiceID.Error(), + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(testcase.name, func(t *testing.T) { + testcase.cmd.Globals.APIClient = testcase.api + + var bs []byte + out := bytes.NewBuffer(bs) + verboseMode := true + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + AutoCloneFlag: testcase.cmd.AutoClone, + APIClient: testcase.api, + Manifest: testcase.cmd.Manifest, + Out: out, + ServiceVersionFlag: testcase.cmd.ServiceVersion, + VerboseMode: verboseMode, + }) + + switch { + case err != nil && testcase.wantError == "": + t.Fatalf("unexpected error getting service details: %v", err) + return + case err != nil && testcase.wantError != "": + testutil.AssertErrorContains(t, err, testcase.wantError) + return + case err == nil && testcase.wantError != "": + t.Fatalf("expected error, have nil (service details: %s, %d)", serviceID, serviceVersion.Number) + case err == nil && testcase.wantError == "": + have, err := testcase.cmd.ConstructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertEqual(t, testcase.want, have) + } + }) + } +} + +func createCommandRequired() *elasticsearch.CreateCommand { + var b bytes.Buffer + + globals := global.Data{ + Config: config.File{}, + Env: config.Environment{}, + Output: &b, + } + globals.APIClient, _ = mock.APIClient(mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + })("token", "endpoint", false) + + return &elasticsearch.CreateCommand{ + Base: argparser.Base{ + Globals: &globals, + }, + Manifest: manifest.Data{ + Flag: manifest.Flag{ + ServiceID: "123", + }, + }, + ServiceVersion: argparser.OptionalServiceVersion{ + OptionalString: argparser.OptionalString{Value: "1"}, + }, + AutoClone: argparser.OptionalAutoClone{ + OptionalBool: argparser.OptionalBool{ + Optional: argparser.Optional{ + WasSet: true, + }, + Value: true, + }, + }, + EndpointName: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "log"}, + Index: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "logs"}, + URL: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "example.com"}, + } +} + +func createCommandAll() *elasticsearch.CreateCommand { + var b bytes.Buffer + + g := global.Data{ + Config: config.File{}, + Env: config.Environment{}, + Output: &b, + } + g.APIClient, _ = mock.APIClient(mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + })("token", "endpoint", false) + + return &elasticsearch.CreateCommand{ + Base: argparser.Base{ + Globals: &g, + }, + Manifest: manifest.Data{ + Flag: manifest.Flag{ + ServiceID: "123", + }, + }, + ServiceVersion: argparser.OptionalServiceVersion{ + OptionalString: argparser.OptionalString{Value: "1"}, + }, + AutoClone: argparser.OptionalAutoClone{ + OptionalBool: argparser.OptionalBool{ + Optional: argparser.Optional{ + WasSet: true, + }, + Value: true, + }, + }, + EndpointName: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "logs"}, + Index: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "logs"}, + URL: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "example.com"}, + Pipeline: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "my_pipeline_id"}, + RequestMaxEntries: argparser.OptionalInt{Optional: argparser.Optional{WasSet: true}, Value: 2}, + RequestMaxBytes: argparser.OptionalInt{Optional: argparser.Optional{WasSet: true}, Value: 2}, + Format: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: `%h %l %u %t "%r" %>s %b`}, + FormatVersion: argparser.OptionalInt{Optional: argparser.Optional{WasSet: true}, Value: 2}, + ResponseCondition: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "Prevent default logging"}, + Placement: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "none"}, + User: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "user"}, + Password: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "password"}, + TLSCACert: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "-----BEGIN CERTIFICATE-----foo"}, + TLSHostname: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "example.com"}, + TLSClientCert: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "-----BEGIN CERTIFICATE-----bar"}, + TLSClientKey: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "-----BEGIN PRIVATE KEY-----bar"}, + } +} + +func createCommandMissingServiceID() *elasticsearch.CreateCommand { + res := createCommandAll() + res.Manifest = manifest.Data{} + return res +} + +func updateCommandNoUpdates() *elasticsearch.UpdateCommand { + var b bytes.Buffer + + g := global.Data{ + Config: config.File{}, + Env: config.Environment{}, + Output: &b, + } + + return &elasticsearch.UpdateCommand{ + Base: argparser.Base{ + Globals: &g, + }, + Manifest: manifest.Data{ + Flag: manifest.Flag{ + ServiceID: "123", + }, + }, + EndpointName: "log", + ServiceVersion: argparser.OptionalServiceVersion{ + OptionalString: argparser.OptionalString{Value: "1"}, + }, + AutoClone: argparser.OptionalAutoClone{ + OptionalBool: argparser.OptionalBool{ + Optional: argparser.Optional{ + WasSet: true, + }, + Value: true, + }, + }, + } +} + +func updateCommandAll() *elasticsearch.UpdateCommand { + var b bytes.Buffer + + g := global.Data{ + Config: config.File{}, + Env: config.Environment{}, + Output: &b, + } + + return &elasticsearch.UpdateCommand{ + Base: argparser.Base{ + Globals: &g, + }, + Manifest: manifest.Data{ + Flag: manifest.Flag{ + ServiceID: "123", + }, + }, + EndpointName: "log", + ServiceVersion: argparser.OptionalServiceVersion{ + OptionalString: argparser.OptionalString{Value: "1"}, + }, + AutoClone: argparser.OptionalAutoClone{ + OptionalBool: argparser.OptionalBool{ + Optional: argparser.Optional{ + WasSet: true, + }, + Value: true, + }, + }, + NewName: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new1"}, + Index: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new2"}, + URL: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new3"}, + Pipeline: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new4"}, + RequestMaxEntries: argparser.OptionalInt{Optional: argparser.Optional{WasSet: true}, Value: 3}, + RequestMaxBytes: argparser.OptionalInt{Optional: argparser.Optional{WasSet: true}, Value: 3}, + User: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new5"}, + Password: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new6"}, + Placement: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new7"}, + Format: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new8"}, + FormatVersion: argparser.OptionalInt{Optional: argparser.Optional{WasSet: true}, Value: 3}, + ResponseCondition: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new9"}, + TLSCACert: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new10"}, + TLSClientCert: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new11"}, + TLSClientKey: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new12"}, + TLSHostname: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new13"}, + } +} + +func updateCommandMissingServiceID() *elasticsearch.UpdateCommand { + res := updateCommandAll() + res.Manifest = manifest.Data{} + return res +} diff --git a/pkg/commands/logging/elasticsearch/list.go b/pkg/commands/logging/elasticsearch/list.go new file mode 100644 index 000000000..85bf79f2f --- /dev/null +++ b/pkg/commands/logging/elasticsearch/list.go @@ -0,0 +1,131 @@ +package elasticsearch + +import ( + "fmt" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// ListCommand calls the Fastly API to list Elasticsearch logging endpoints. +type ListCommand struct { + argparser.Base + argparser.JSONOutput + + Input fastly.ListElasticsearchInput + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion +} + +// NewListCommand returns a usable command registered under the parent. +func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { + c := ListCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("list", "List Elasticsearch endpoints on a Fastly service version") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + return &c +} + +// Exec invokes the application logic for the command. +func (c *ListCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fsterr.ServiceVersion(serviceVersion), + }) + return err + } + + c.Input.ServiceID = serviceID + c.Input.ServiceVersion = fastly.ToValue(serviceVersion.Number) + + o, err := c.Globals.APIClient.ListElasticsearch(&c.Input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + if !c.Globals.Verbose() { + tw := text.NewTable(out) + tw.AddHeader("SERVICE", "VERSION", "NAME") + for _, elasticsearch := range o { + tw.AddLine( + fastly.ToValue(elasticsearch.ServiceID), + fastly.ToValue(elasticsearch.ServiceVersion), + fastly.ToValue(elasticsearch.Name), + ) + } + tw.Print() + return nil + } + + fmt.Fprintf(out, "Version: %d\n", c.Input.ServiceVersion) + for i, elasticsearch := range o { + fmt.Fprintf(out, "\tElasticsearch %d/%d\n", i+1, len(o)) + fmt.Fprintf(out, "\t\tService ID: %s\n", fastly.ToValue(elasticsearch.ServiceID)) + fmt.Fprintf(out, "\t\tVersion: %d\n", fastly.ToValue(elasticsearch.ServiceVersion)) + fmt.Fprintf(out, "\t\tName: %s\n", fastly.ToValue(elasticsearch.Name)) + fmt.Fprintf(out, "\t\tIndex: %s\n", fastly.ToValue(elasticsearch.Index)) + fmt.Fprintf(out, "\t\tURL: %s\n", fastly.ToValue(elasticsearch.URL)) + fmt.Fprintf(out, "\t\tPipeline: %s\n", fastly.ToValue(elasticsearch.Pipeline)) + fmt.Fprintf(out, "\t\tTLS CA certificate: %s\n", fastly.ToValue(elasticsearch.TLSCACert)) + fmt.Fprintf(out, "\t\tTLS client certificate: %s\n", fastly.ToValue(elasticsearch.TLSClientCert)) + fmt.Fprintf(out, "\t\tTLS client key: %s\n", fastly.ToValue(elasticsearch.TLSClientKey)) + fmt.Fprintf(out, "\t\tTLS hostname: %s\n", fastly.ToValue(elasticsearch.TLSHostname)) + fmt.Fprintf(out, "\t\tUser: %s\n", fastly.ToValue(elasticsearch.User)) + fmt.Fprintf(out, "\t\tPassword: %s\n", fastly.ToValue(elasticsearch.Password)) + fmt.Fprintf(out, "\t\tFormat: %s\n", fastly.ToValue(elasticsearch.Format)) + fmt.Fprintf(out, "\t\tFormat version: %d\n", fastly.ToValue(elasticsearch.FormatVersion)) + fmt.Fprintf(out, "\t\tResponse condition: %s\n", fastly.ToValue(elasticsearch.ResponseCondition)) + fmt.Fprintf(out, "\t\tPlacement: %s\n", fastly.ToValue(elasticsearch.Placement)) + } + fmt.Fprintln(out) + + return nil +} diff --git a/pkg/commands/logging/elasticsearch/root.go b/pkg/commands/logging/elasticsearch/root.go new file mode 100644 index 000000000..bf889c5b3 --- /dev/null +++ b/pkg/commands/logging/elasticsearch/root.go @@ -0,0 +1,31 @@ +package elasticsearch + +import ( + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" +) + +// RootCommand is the parent command for all subcommands in this package. +// It should be installed under the primary root command. +type RootCommand struct { + argparser.Base + // no flags +} + +// CommandName is the string to be used to invoke this command. +const CommandName = "elasticsearch" + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent argparser.Registerer, g *global.Data) *RootCommand { + var c RootCommand + c.Globals = g + c.CmdClause = parent.Command(CommandName, "Manipulate Fastly service version Elasticsearch logging endpoints") + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { + panic("unreachable") +} diff --git a/pkg/commands/logging/elasticsearch/update.go b/pkg/commands/logging/elasticsearch/update.go new file mode 100644 index 000000000..c24b106b4 --- /dev/null +++ b/pkg/commands/logging/elasticsearch/update.go @@ -0,0 +1,215 @@ +package elasticsearch + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/logging/common" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/text" +) + +// UpdateCommand calls the Fastly API to update an Elasticsearch logging endpoint. +type UpdateCommand struct { + argparser.Base + Manifest manifest.Data + + // Required. + EndpointName string // Can't shadow argparser.Base method Name(). + ServiceName argparser.OptionalServiceNameID + ServiceVersion argparser.OptionalServiceVersion + + // Optional. + AutoClone argparser.OptionalAutoClone + NewName argparser.OptionalString + Index argparser.OptionalString + URL argparser.OptionalString + Pipeline argparser.OptionalString + RequestMaxEntries argparser.OptionalInt + RequestMaxBytes argparser.OptionalInt + User argparser.OptionalString + Password argparser.OptionalString + TLSCACert argparser.OptionalString + TLSClientCert argparser.OptionalString + TLSClientKey argparser.OptionalString + TLSHostname argparser.OptionalString + Format argparser.OptionalString + FormatVersion argparser.OptionalInt + Placement argparser.OptionalString + ResponseCondition argparser.OptionalString +} + +// NewUpdateCommand returns a usable command registered under the parent. +func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { + c := UpdateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("update", "Update an Elasticsearch logging endpoint on a Fastly service version") + + // Required. + c.CmdClause.Flag("name", "The name of the Elasticsearch logging object").Short('n').Required().StringVar(&c.EndpointName) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.ServiceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.AutoClone.Set, + Dst: &c.AutoClone.Value, + }) + common.Format(c.CmdClause, &c.Format) + common.FormatVersion(c.CmdClause, &c.FormatVersion) + c.CmdClause.Flag("index", `The name of the Elasticsearch index to send documents (logs) to. The index must follow the Elasticsearch index format rules (https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-create-index.html). We support strftime (http://man7.org/linux/man-pages/man3/strftime.3.html) interpolated variables inside braces prefixed with a pound symbol. For example, #{%F} will interpolate as YYYY-MM-DD with today's date`).Action(c.Index.Set).StringVar(&c.Index.Value) + c.CmdClause.Flag("new-name", "New name of the Elasticsearch logging object").Action(c.NewName.Set).StringVar(&c.NewName.Value) + c.CmdClause.Flag("pipeline", "The ID of the Elasticsearch ingest pipeline to apply pre-process transformations to before indexing. For example my_pipeline_id. Learn more about creating a pipeline in the Elasticsearch docs (https://www.elastic.co/guide/en/elasticsearch/reference/current/ingest.html)").Action(c.Password.Set).StringVar(&c.Pipeline.Value) + common.Placement(c.CmdClause, &c.Placement) + c.CmdClause.Flag("request-max-bytes", "Maximum size of log batch, if non-zero. Defaults to 100MB").Action(c.RequestMaxBytes.Set).IntVar(&c.RequestMaxBytes.Value) + c.CmdClause.Flag("request-max-entries", "Maximum number of logs to append to a batch, if non-zero. Defaults to 10k").Action(c.RequestMaxEntries.Set).IntVar(&c.RequestMaxEntries.Value) + common.ResponseCondition(c.CmdClause, &c.ResponseCondition) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.ServiceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.ServiceName.Value, + }) + common.TLSCACert(c.CmdClause, &c.TLSCACert) + common.TLSClientCert(c.CmdClause, &c.TLSClientCert) + common.TLSClientKey(c.CmdClause, &c.TLSClientKey) + common.TLSHostname(c.CmdClause, &c.TLSHostname) + c.CmdClause.Flag("url", "The URL to stream logs to. Must use HTTPS.").Action(c.URL.Set).StringVar(&c.URL.Value) + return &c +} + +// ConstructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *UpdateCommand) ConstructInput(serviceID string, serviceVersion int) (*fastly.UpdateElasticsearchInput, error) { + input := fastly.UpdateElasticsearchInput{ + ServiceID: serviceID, + ServiceVersion: serviceVersion, + Name: c.EndpointName, + } + + if c.NewName.WasSet { + input.NewName = &c.NewName.Value + } + + if c.Index.WasSet { + input.Index = &c.Index.Value + } + + if c.URL.WasSet { + input.URL = &c.URL.Value + } + + if c.Pipeline.WasSet { + input.Pipeline = &c.Pipeline.Value + } + + if c.RequestMaxEntries.WasSet { + input.RequestMaxEntries = &c.RequestMaxEntries.Value + } + + if c.RequestMaxBytes.WasSet { + input.RequestMaxBytes = &c.RequestMaxBytes.Value + } + + if c.User.WasSet { + input.User = &c.User.Value + } + + if c.Password.WasSet { + input.Password = &c.Password.Value + } + + if c.TLSCACert.WasSet { + input.TLSCACert = &c.TLSCACert.Value + } + + if c.TLSClientCert.WasSet { + input.TLSClientCert = &c.TLSClientCert.Value + } + + if c.TLSClientKey.WasSet { + input.TLSClientKey = &c.TLSClientKey.Value + } + + if c.TLSHostname.WasSet { + input.TLSHostname = &c.TLSHostname.Value + } + + if c.Format.WasSet { + input.Format = fastly.ToPointer(argparser.Content(c.Format.Value)) + } + + if c.FormatVersion.WasSet { + input.FormatVersion = &c.FormatVersion.Value + } + + if c.ResponseCondition.WasSet { + input.ResponseCondition = &c.ResponseCondition.Value + } + + if c.Placement.WasSet { + input.Placement = &c.Placement.Value + } + + return &input, nil +} + +// Exec invokes the application logic for the command. +func (c *UpdateCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.AutoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.ServiceName, + ServiceVersionFlag: c.ServiceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + input, err := c.ConstructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + elasticsearch, err := c.Globals.APIClient.UpdateElasticsearch(input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + text.Success(out, + "Updated Elasticsearch logging endpoint %s (service %s version %d)", + fastly.ToValue(elasticsearch.Name), + fastly.ToValue(elasticsearch.ServiceID), + fastly.ToValue(elasticsearch.ServiceVersion), + ) + return nil +} diff --git a/pkg/commands/logging/ftp/create.go b/pkg/commands/logging/ftp/create.go new file mode 100644 index 000000000..e2ebc3729 --- /dev/null +++ b/pkg/commands/logging/ftp/create.go @@ -0,0 +1,205 @@ +package ftp + +import ( + "fmt" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/logging/common" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/text" +) + +// CreateCommand calls the Fastly API to create an FTP logging endpoint. +type CreateCommand struct { + argparser.Base + Manifest manifest.Data + + // Required. + ServiceName argparser.OptionalServiceNameID + ServiceVersion argparser.OptionalServiceVersion + + // Optional. + Address argparser.OptionalString + AutoClone argparser.OptionalAutoClone + CompressionCodec argparser.OptionalString + EndpointName argparser.OptionalString // Can't shadow argparser.Base method Name(). + Format argparser.OptionalString + FormatVersion argparser.OptionalInt + GzipLevel argparser.OptionalInt + Password argparser.OptionalString + Path argparser.OptionalString + Period argparser.OptionalInt + Placement argparser.OptionalString + Port argparser.OptionalInt + ResponseCondition argparser.OptionalString + TimestampFormat argparser.OptionalString + Username argparser.OptionalString +} + +// NewCreateCommand returns a usable command registered under the parent. +func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { + c := CreateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("create", "Create an FTP logging endpoint on a Fastly service version").Alias("add") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.ServiceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.AutoClone.Set, + Dst: &c.AutoClone.Value, + }) + c.CmdClause.Flag("address", "An hostname or IPv4 address").Action(c.Address.Set).StringVar(&c.Address.Value) + common.CompressionCodec(c.CmdClause, &c.CompressionCodec) + c.CmdClause.Flag("name", "The name of the FTP logging object. Used as a primary key for API access").Short('n').Action(c.EndpointName.Set).StringVar(&c.EndpointName.Value) + common.Format(c.CmdClause, &c.Format) + common.FormatVersion(c.CmdClause, &c.FormatVersion) + common.GzipLevel(c.CmdClause, &c.GzipLevel) + c.CmdClause.Flag("password", "The password for the server (for anonymous use an email address)").Action(c.Password.Set).StringVar(&c.Password.Value) + c.CmdClause.Flag("path", "The path to upload log files to. If the path ends in / then it is treated as a directory").Action(c.Path.Set).StringVar(&c.Path.Value) + common.Period(c.CmdClause, &c.Period) + common.Placement(c.CmdClause, &c.Placement) + c.CmdClause.Flag("port", "The port number").Action(c.Port.Set).IntVar(&c.Port.Value) + common.ResponseCondition(c.CmdClause, &c.ResponseCondition) + common.TimestampFormat(c.CmdClause, &c.TimestampFormat) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.ServiceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.ServiceName.Value, + }) + c.CmdClause.Flag("user", "The username for the server (can be anonymous)").Action(c.Username.Set).StringVar(&c.Username.Value) + return &c +} + +// ConstructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *CreateCommand) ConstructInput(serviceID string, serviceVersion int) (*fastly.CreateFTPInput, error) { + var input fastly.CreateFTPInput + + input.ServiceID = serviceID + input.ServiceVersion = serviceVersion + if c.EndpointName.WasSet { + input.Name = &c.EndpointName.Value + } + if c.Address.WasSet { + input.Address = &c.Address.Value + } + if c.Username.WasSet { + input.Username = &c.Username.Value + } + if c.Password.WasSet { + input.Password = &c.Password.Value + } + + // The following blocks enforces the mutual exclusivity of the + // CompressionCodec and GzipLevel flags. + if c.CompressionCodec.WasSet && c.GzipLevel.WasSet { + return nil, fmt.Errorf("error parsing arguments: the --compression-codec flag is mutually exclusive with the --gzip-level flag") + } + + if c.Port.WasSet { + input.Port = &c.Port.Value + } + + if c.Path.WasSet { + input.Path = &c.Path.Value + } + + if c.Period.WasSet { + input.Period = &c.Period.Value + } + + if c.Format.WasSet { + input.Format = fastly.ToPointer(argparser.Content(c.Format.Value)) + } + + if c.FormatVersion.WasSet { + input.FormatVersion = &c.FormatVersion.Value + } + + if c.GzipLevel.WasSet { + input.GzipLevel = &c.GzipLevel.Value + } + + if c.ResponseCondition.WasSet { + input.ResponseCondition = &c.ResponseCondition.Value + } + + if c.TimestampFormat.WasSet { + input.TimestampFormat = &c.TimestampFormat.Value + } + + if c.Placement.WasSet { + input.Placement = &c.Placement.Value + } + + if c.CompressionCodec.WasSet { + input.CompressionCodec = &c.CompressionCodec.Value + } + + return &input, nil +} + +// Exec invokes the application logic for the command. +func (c *CreateCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.AutoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.ServiceName, + ServiceVersionFlag: c.ServiceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + input, err := c.ConstructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + d, err := c.Globals.APIClient.CreateFTP(input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + text.Success(out, + "Created FTP logging endpoint %s (service %s version %d)", + fastly.ToValue(d.Name), + fastly.ToValue(d.ServiceID), + fastly.ToValue(d.ServiceVersion), + ) + return nil +} diff --git a/pkg/commands/logging/ftp/delete.go b/pkg/commands/logging/ftp/delete.go new file mode 100644 index 000000000..1165ddbd2 --- /dev/null +++ b/pkg/commands/logging/ftp/delete.go @@ -0,0 +1,94 @@ +package ftp + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// DeleteCommand calls the Fastly API to delete an FTP logging endpoint. +type DeleteCommand struct { + argparser.Base + Input fastly.DeleteFTPInput + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion + autoClone argparser.OptionalAutoClone +} + +// NewDeleteCommand returns a usable command registered under the parent. +func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { + c := DeleteCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("delete", "Delete an FTP logging endpoint on a Fastly service version").Alias("remove") + + // Required. + c.CmdClause.Flag("name", "The name of the FTP logging object").Short('n').Required().StringVar(&c.Input.Name) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.autoClone.Set, + Dst: &c.autoClone.Value, + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + return &c +} + +// Exec invokes the application logic for the command. +func (c *DeleteCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.autoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + c.Input.ServiceID = serviceID + c.Input.ServiceVersion = fastly.ToValue(serviceVersion.Number) + + if err := c.Globals.APIClient.DeleteFTP(&c.Input); err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + text.Success(out, "Deleted FTP logging endpoint %s (service %s version %d)", c.Input.Name, c.Input.ServiceID, c.Input.ServiceVersion) + return nil +} diff --git a/pkg/commands/logging/ftp/describe.go b/pkg/commands/logging/ftp/describe.go new file mode 100644 index 000000000..44bdf34b0 --- /dev/null +++ b/pkg/commands/logging/ftp/describe.go @@ -0,0 +1,114 @@ +package ftp + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// DescribeCommand calls the Fastly API to describe an FTP logging endpoint. +type DescribeCommand struct { + argparser.Base + argparser.JSONOutput + + Input fastly.GetFTPInput + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion +} + +// NewDescribeCommand returns a usable command registered under the parent. +func NewDescribeCommand(parent argparser.Registerer, g *global.Data) *DescribeCommand { + c := DescribeCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("describe", "Show detailed information about an FTP logging endpoint on a Fastly service version").Alias("get") + c.RegisterFlagBool(c.JSONFlag()) // --json + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + c.CmdClause.Flag("name", "The name of the FTP logging object").Short('n').Required().StringVar(&c.Input.Name) + return &c +} + +// Exec invokes the application logic for the command. +func (c *DescribeCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fsterr.ServiceVersion(serviceVersion), + }) + return err + } + + c.Input.ServiceID = serviceID + c.Input.ServiceVersion = fastly.ToValue(serviceVersion.Number) + + o, err := c.Globals.APIClient.GetFTP(&c.Input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + lines := text.Lines{ + "Address": fastly.ToValue(o.Address), + "Compression codec": fastly.ToValue(o.CompressionCodec), + "Format version": fastly.ToValue(o.FormatVersion), + "Format": fastly.ToValue(o.Format), + "GZip level": fastly.ToValue(o.GzipLevel), + "Name": fastly.ToValue(o.Name), + "Password": fastly.ToValue(o.Password), + "Path": fastly.ToValue(o.Path), + "Period": fastly.ToValue(o.Period), + "Placement": fastly.ToValue(o.Placement), + "Port": fastly.ToValue(o.Port), + "Public key": fastly.ToValue(o.PublicKey), + "Response condition": fastly.ToValue(o.ResponseCondition), + "Timestamp format": fastly.ToValue(o.TimestampFormat), + "Username": fastly.ToValue(o.Username), + "Version": fastly.ToValue(o.ServiceVersion), + } + if !c.Globals.Verbose() { + lines["Service ID"] = fastly.ToValue(o.ServiceID) + } + text.PrintLines(out, lines) + + return nil +} diff --git a/pkg/logging/ftp/doc.go b/pkg/commands/logging/ftp/doc.go similarity index 100% rename from pkg/logging/ftp/doc.go rename to pkg/commands/logging/ftp/doc.go diff --git a/pkg/commands/logging/ftp/ftp_integration_test.go b/pkg/commands/logging/ftp/ftp_integration_test.go new file mode 100644 index 000000000..a17c6c203 --- /dev/null +++ b/pkg/commands/logging/ftp/ftp_integration_test.go @@ -0,0 +1,506 @@ +package ftp_test + +import ( + "bytes" + "errors" + "io" + "strings" + "testing" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/app" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" +) + +func TestFTPCreate(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging ftp create --service-id 123 --version 1 --name log --address example.com --user anonymous --password foo@example.com --compression-codec zstd --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + CreateFTPFn: createFTPOK, + }, + wantOutput: "Created FTP logging endpoint log (service 123 version 4)", + }, + { + args: args("logging ftp create --service-id 123 --version 1 --name log --address example.com --user anonymous --password foo@example.com --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + CreateFTPFn: createFTPError, + }, + wantError: errTest.Error(), + }, + { + args: args("logging ftp create --service-id 123 --version 1 --name log --address example.com --user anonymous --password foo@example.com --compression-codec zstd --gzip-level 9 --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + }, + wantError: "error parsing arguments: the --compression-codec flag is mutually exclusive with the --gzip-level flag", + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertStringContains(t, stdout.String(), testcase.wantOutput) + }) + } +} + +func TestFTPList(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging ftp list --service-id 123 --version 1"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListFTPsFn: listFTPsOK, + }, + wantOutput: listFTPsShortOutput, + }, + { + args: args("logging ftp list --service-id 123 --version 1 --verbose"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListFTPsFn: listFTPsOK, + }, + wantOutput: listFTPsVerboseOutput, + }, + { + args: args("logging ftp list --service-id 123 --version 1 -v"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListFTPsFn: listFTPsOK, + }, + wantOutput: listFTPsVerboseOutput, + }, + { + args: args("logging ftp --verbose list --service-id 123 --version 1"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListFTPsFn: listFTPsOK, + }, + wantOutput: listFTPsVerboseOutput, + }, + { + args: args("logging -v ftp list --service-id 123 --version 1"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListFTPsFn: listFTPsOK, + }, + wantOutput: listFTPsVerboseOutput, + }, + { + args: args("logging ftp list --service-id 123 --version 1"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListFTPsFn: listFTPsError, + }, + wantError: errTest.Error(), + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertString(t, testcase.wantOutput, stdout.String()) + }) + } +} + +func TestFTPDescribe(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging ftp describe --service-id 123 --version 1"), + wantError: "error parsing arguments: required flag --name not provided", + }, + { + args: args("logging ftp describe --service-id 123 --version 1 --name logs"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + GetFTPFn: getFTPError, + }, + wantError: errTest.Error(), + }, + { + args: args("logging ftp describe --service-id 123 --version 1 --name logs"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + GetFTPFn: getFTPOK, + }, + wantOutput: describeFTPOutput, + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertString(t, testcase.wantOutput, stdout.String()) + }) + } +} + +func TestFTPUpdate(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging ftp update --service-id 123 --version 1 --new-name log"), + wantError: "error parsing arguments: required flag --name not provided", + }, + { + args: args("logging ftp update --service-id 123 --version 1 --name logs --new-name log --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + UpdateFTPFn: updateFTPError, + }, + wantError: errTest.Error(), + }, + { + args: args("logging ftp update --service-id 123 --version 1 --name logs --new-name log --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + UpdateFTPFn: updateFTPOK, + }, + wantOutput: "Updated FTP logging endpoint log (service 123 version 4)", + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertStringContains(t, stdout.String(), testcase.wantOutput) + }) + } +} + +func TestFTPDelete(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging ftp delete --service-id 123 --version 1"), + wantError: "error parsing arguments: required flag --name not provided", + }, + { + args: args("logging ftp delete --service-id 123 --version 1 --name logs --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + DeleteFTPFn: deleteFTPError, + }, + wantError: errTest.Error(), + }, + { + args: args("logging ftp delete --service-id 123 --version 1 --name logs --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + DeleteFTPFn: deleteFTPOK, + }, + wantOutput: "Deleted FTP logging endpoint logs (service 123 version 4)", + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertStringContains(t, stdout.String(), testcase.wantOutput) + }) + } +} + +var errTest = errors.New("fixture error") + +func createFTPOK(i *fastly.CreateFTPInput) (*fastly.FTP, error) { + return &fastly.FTP{ + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: i.Name, + CompressionCodec: i.CompressionCodec, + }, nil +} + +func createFTPError(_ *fastly.CreateFTPInput) (*fastly.FTP, error) { + return nil, errTest +} + +func listFTPsOK(i *fastly.ListFTPsInput) ([]*fastly.FTP, error) { + return []*fastly.FTP{ + { + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("logs"), + Address: fastly.ToPointer("example.com"), + Port: fastly.ToPointer(123), + Username: fastly.ToPointer("anonymous"), + Password: fastly.ToPointer("foo@example.com"), + PublicKey: fastly.ToPointer(pgpPublicKey()), + Path: fastly.ToPointer("logs/"), + Period: fastly.ToPointer(3600), + GzipLevel: fastly.ToPointer(9), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + FormatVersion: fastly.ToPointer(2), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + TimestampFormat: fastly.ToPointer("%Y-%m-%dT%H:%M:%S.000"), + Placement: fastly.ToPointer("none"), + CompressionCodec: fastly.ToPointer("zstd"), + }, + { + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("analytics"), + Address: fastly.ToPointer("127.0.0.1"), + Port: fastly.ToPointer(456), + Username: fastly.ToPointer("foo"), + Password: fastly.ToPointer("password"), + PublicKey: fastly.ToPointer(pgpPublicKey()), + Path: fastly.ToPointer("logs/"), + Period: fastly.ToPointer(86400), + GzipLevel: fastly.ToPointer(9), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + FormatVersion: fastly.ToPointer(2), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + TimestampFormat: fastly.ToPointer("%Y-%m-%dT%H:%M:%S.000"), + Placement: fastly.ToPointer("none"), + CompressionCodec: fastly.ToPointer("zstd"), + }, + }, nil +} + +func listFTPsError(_ *fastly.ListFTPsInput) ([]*fastly.FTP, error) { + return nil, errTest +} + +var listFTPsShortOutput = strings.TrimSpace(` +SERVICE VERSION NAME +123 1 logs +123 1 analytics +`) + "\n" + +var listFTPsVerboseOutput = strings.TrimSpace(` +Fastly API endpoint: https://api.fastly.com +Fastly API token provided via config file (profile: user) + +Service ID (via --service-id): 123 + +Version: 1 + FTP 1/2 + Service ID: 123 + Version: 1 + Name: logs + Address: example.com + Port: 123 + Username: anonymous + Password: foo@example.com + Public key: `+pgpPublicKey()+` + Path: logs/ + Period: 3600 + GZip level: 9 + Format: %h %l %u %t "%r" %>s %b + Format version: 2 + Response condition: Prevent default logging + Timestamp format: %Y-%m-%dT%H:%M:%S.000 + Placement: none + Compression codec: zstd + FTP 2/2 + Service ID: 123 + Version: 1 + Name: analytics + Address: 127.0.0.1 + Port: 456 + Username: foo + Password: password + Public key: `+pgpPublicKey()+` + Path: logs/ + Period: 86400 + GZip level: 9 + Format: %h %l %u %t "%r" %>s %b + Format version: 2 + Response condition: Prevent default logging + Timestamp format: %Y-%m-%dT%H:%M:%S.000 + Placement: none + Compression codec: zstd +`) + "\n\n" + +func getFTPOK(i *fastly.GetFTPInput) (*fastly.FTP, error) { + return &fastly.FTP{ + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("logs"), + Address: fastly.ToPointer("example.com"), + Port: fastly.ToPointer(123), + Username: fastly.ToPointer("anonymous"), + Password: fastly.ToPointer("foo@example.com"), + PublicKey: fastly.ToPointer(pgpPublicKey()), + Path: fastly.ToPointer("logs/"), + Period: fastly.ToPointer(3600), + GzipLevel: fastly.ToPointer(9), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + FormatVersion: fastly.ToPointer(2), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + TimestampFormat: fastly.ToPointer("%Y-%m-%dT%H:%M:%S.000"), + Placement: fastly.ToPointer("none"), + CompressionCodec: fastly.ToPointer("zstd"), + }, nil +} + +func getFTPError(_ *fastly.GetFTPInput) (*fastly.FTP, error) { + return nil, errTest +} + +var describeFTPOutput = "\n" + strings.TrimSpace(` +Address: example.com +Compression codec: zstd +Format: %h %l %u %t "%r" %>s %b +Format version: 2 +GZip level: 9 +Name: logs +Password: foo@example.com +Path: logs/ +Period: 3600 +Placement: none +Port: 123 +Public key: `+pgpPublicKey()+` +Response condition: Prevent default logging +Service ID: 123 +Timestamp format: %Y-%m-%dT%H:%M:%S.000 +Username: anonymous +Version: 1 +`) + "\n" + +func updateFTPOK(i *fastly.UpdateFTPInput) (*fastly.FTP, error) { + return &fastly.FTP{ + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("log"), + Address: fastly.ToPointer("example.com"), + Port: fastly.ToPointer(123), + Username: fastly.ToPointer("anonymous"), + Password: fastly.ToPointer("foo@example.com"), + PublicKey: fastly.ToPointer(pgpPublicKey()), + Path: fastly.ToPointer("logs/"), + Period: fastly.ToPointer(3600), + GzipLevel: fastly.ToPointer(9), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + FormatVersion: fastly.ToPointer(2), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + TimestampFormat: fastly.ToPointer("%Y-%m-%dT%H:%M:%S.000"), + Placement: fastly.ToPointer("none"), + CompressionCodec: fastly.ToPointer("zstd"), + }, nil +} + +func updateFTPError(_ *fastly.UpdateFTPInput) (*fastly.FTP, error) { + return nil, errTest +} + +func deleteFTPOK(_ *fastly.DeleteFTPInput) error { + return nil +} + +func deleteFTPError(_ *fastly.DeleteFTPInput) error { + return errTest +} + +// pgpPublicKey returns a PEM encoded PGP public key suitable for testing. +func pgpPublicKey() string { + return strings.TrimSpace(`-----BEGIN PGP PUBLIC KEY BLOCK----- +mQENBFyUD8sBCACyFnB39AuuTygseek+eA4fo0cgwva6/FSjnWq7riouQee8GgQ/ +ibXTRyv4iVlwI12GswvMTIy7zNvs1R54i0qvsLr+IZ4GVGJqs6ZJnvQcqe3xPoR4 +8AnBfw90o32r/LuHf6QCJXi+AEu35koNlNAvLJ2B+KACaNB7N0EeWmqpV/1V2k9p +lDYk+th7LcCuaFNGqKS/PrMnnMqR6VDLCjHhNx4KR79b0Twm/2qp6an3hyNRu8Gn +dwxpf1/BUu3JWf+LqkN4Y3mbOmSUL3MaJNvyQguUzTfS0P0uGuBDHrJCVkMZCzDB +89ag55jCPHyGeHBTd02gHMWzsg3WMBWvCsrzABEBAAG0JXRlcnJhZm9ybSAodGVz +dCkgPHRlc3RAdGVycmFmb3JtLmNvbT6JAU4EEwEIADgWIQSHYyc6Kj9l6HzQsau6 +vFFc9jxV/wUCXJQPywIbAwULCQgHAgYVCgkICwIEFgIDAQIeAQIXgAAKCRC6vFFc +9jxV/815CAClb32OxV7wG01yF97TzlyTl8TnvjMtoG29Mw4nSyg+mjM3b8N7iXm9 +OLX59fbDAWtBSldSZE22RXd3CvlFOG/EnKBXSjBtEqfyxYSnyOPkMPBYWGL/ApkX +SvPYJ4LKdvipYToKFh3y9kk2gk1DcDBDyaaHvR+3rv1u3aoy7/s2EltAfDS3ZQIq +7/cWTLJml/lleeB/Y6rPj8xqeCYhE5ahw9gsV/Mdqatl24V9Tks30iijx0Hhw+Gx +kATUikMGr2GDVqoIRga5kXI7CzYff4rkc0Twn47fMHHHe/KY9M2yVnMHUXmAZwbG +M1cMI/NH1DjevCKdGBLcRJlhuLPKF/anuQENBFyUD8sBCADIpd7r7GuPd6n/Ikxe +u6h7umV6IIPoAm88xCYpTbSZiaK30Svh6Ywra9jfE2KlU9o6Y/art8ip0VJ3m07L +4RSfSpnzqgSwdjSq5hNour2Fo/BzYhK7yaz2AzVSbe33R0+RYhb4b/6N+bKbjwGF +ftCsqVFMH+PyvYkLbvxyQrHlA9woAZaNThI1ztO5rGSnGUR8xt84eup28WIFKg0K +UEGUcTzz+8QGAwAra+0ewPXo/AkO+8BvZjDidP417u6gpBHOJ9qYIcO9FxHeqFyu +YrjlrxowEgXn5wO8xuNz6Vu1vhHGDHGDsRbZF8pv1d5O+0F1G7ttZ2GRRgVBZPwi +kiyRABEBAAGJATYEGAEIACAWIQSHYyc6Kj9l6HzQsau6vFFc9jxV/wUCXJQPywIb +DAAKCRC6vFFc9jxV/9YOCACe8qmOSnKQpQfW+PqYOqo3dt7JyweTs3FkD6NT8Zml +dYy/vkstbTjPpX6aTvUZjkb46BVi7AOneVHpD5GBqvRsZ9iVgDYHaehmLCdKiG5L +3Tp90NN+QY5WDbsGmsyk6+6ZMYejb4qYfweQeduOj27aavCJdLkCYMoRKfcFYI8c +FaNmEfKKy/r1PO20NXEG6t9t05K/frHy6ZG8bCNYdpagfFVot47r9JaQqWlTNtIR +5+zkkSq/eG9BEtRij3a6cTdQbktdBzx2KBeI0PYc1vlZR0LpuFKZqY9vlE6vTGLR +wMfrTEOvx0NxUM3rpaCgEmuWbB1G1Hu371oyr4srrr+N +=28dr +-----END PGP PUBLIC KEY BLOCK----- +`) +} diff --git a/pkg/commands/logging/ftp/ftp_test.go b/pkg/commands/logging/ftp/ftp_test.go new file mode 100644 index 000000000..0f018ca4e --- /dev/null +++ b/pkg/commands/logging/ftp/ftp_test.go @@ -0,0 +1,372 @@ +package ftp_test + +import ( + "bytes" + "testing" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/logging/ftp" + "github.com/fastly/cli/pkg/config" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" +) + +func TestCreateFTPInput(t *testing.T) { + for _, testcase := range []struct { + name string + cmd *ftp.CreateCommand + want *fastly.CreateFTPInput + wantError string + }{ + { + name: "required values set flag serviceID", + cmd: createCommandRequired(), + want: &fastly.CreateFTPInput{ + ServiceID: "123", + ServiceVersion: 4, + Name: fastly.ToPointer("log"), + Address: fastly.ToPointer("example.com"), + Username: fastly.ToPointer("user"), + Password: fastly.ToPointer("password"), + }, + }, + { + name: "all values set flag serviceID", + cmd: createCommandAll(), + want: &fastly.CreateFTPInput{ + ServiceID: "123", + ServiceVersion: 4, + Name: fastly.ToPointer("log"), + Address: fastly.ToPointer("example.com"), + Port: fastly.ToPointer(22), + Username: fastly.ToPointer("user"), + Password: fastly.ToPointer("password"), + Path: fastly.ToPointer("/logs"), + Period: fastly.ToPointer(3600), + FormatVersion: fastly.ToPointer(2), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + TimestampFormat: fastly.ToPointer("%Y-%m-%dT%H:%M:%S.000"), + Placement: fastly.ToPointer("none"), + CompressionCodec: fastly.ToPointer("zstd"), + }, + }, + { + name: "error missing serviceID", + cmd: createCommandMissingServiceID(), + want: nil, + wantError: errors.ErrNoServiceID.Error(), + }, + } { + t.Run(testcase.name, func(t *testing.T) { + var bs []byte + out := bytes.NewBuffer(bs) + verboseMode := true + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + AutoCloneFlag: testcase.cmd.AutoClone, + APIClient: testcase.cmd.Globals.APIClient, + Manifest: testcase.cmd.Manifest, + Out: out, + ServiceVersionFlag: testcase.cmd.ServiceVersion, + VerboseMode: verboseMode, + }) + + switch { + case err != nil && testcase.wantError == "": + t.Fatalf("unexpected error getting service details: %v", err) + return + case err != nil && testcase.wantError != "": + testutil.AssertErrorContains(t, err, testcase.wantError) + return + case err == nil && testcase.wantError != "": + t.Fatalf("expected error, have nil (service details: %s, %d)", serviceID, serviceVersion.Number) + case err == nil && testcase.wantError == "": + have, err := testcase.cmd.ConstructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertEqual(t, testcase.want, have) + } + }) + } +} + +func TestUpdateFTPInput(t *testing.T) { + scenarios := []struct { + name string + cmd *ftp.UpdateCommand + api mock.API + want *fastly.UpdateFTPInput + wantError string + }{ + { + name: "no updates", + cmd: updateCommandNoUpdates(), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + GetFTPFn: getFTPOK, + }, + want: &fastly.UpdateFTPInput{ + ServiceID: "123", + ServiceVersion: 4, + Name: "log", + }, + }, + { + name: "all values set flag serviceID", + cmd: updateCommandAll(), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + GetFTPFn: getFTPOK, + }, + want: &fastly.UpdateFTPInput{ + ServiceID: "123", + ServiceVersion: 4, + Name: "log", + NewName: fastly.ToPointer("new1"), + Address: fastly.ToPointer("new2"), + Port: fastly.ToPointer(23), + PublicKey: fastly.ToPointer("new10"), + Username: fastly.ToPointer("new3"), + Password: fastly.ToPointer("new4"), + Path: fastly.ToPointer("new5"), + Period: fastly.ToPointer(3601), + FormatVersion: fastly.ToPointer(3), + GzipLevel: fastly.ToPointer(0), + Format: fastly.ToPointer("new6"), + ResponseCondition: fastly.ToPointer("new7"), + TimestampFormat: fastly.ToPointer("new8"), + Placement: fastly.ToPointer("new9"), + CompressionCodec: fastly.ToPointer("new11"), + }, + }, + { + name: "error missing serviceID", + cmd: updateCommandMissingServiceID(), + want: nil, + wantError: errors.ErrNoServiceID.Error(), + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(testcase.name, func(t *testing.T) { + testcase.cmd.Globals.APIClient = testcase.api + + var bs []byte + out := bytes.NewBuffer(bs) + verboseMode := true + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + AutoCloneFlag: testcase.cmd.AutoClone, + APIClient: testcase.api, + Manifest: testcase.cmd.Manifest, + Out: out, + ServiceVersionFlag: testcase.cmd.ServiceVersion, + VerboseMode: verboseMode, + }) + + switch { + case err != nil && testcase.wantError == "": + t.Fatalf("unexpected error getting service details: %v", err) + return + case err != nil && testcase.wantError != "": + testutil.AssertErrorContains(t, err, testcase.wantError) + return + case err == nil && testcase.wantError != "": + t.Fatalf("expected error, have nil (service details: %s, %d)", serviceID, serviceVersion.Number) + case err == nil && testcase.wantError == "": + have, err := testcase.cmd.ConstructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertEqual(t, testcase.want, have) + } + }) + } +} + +func createCommandRequired() *ftp.CreateCommand { + var b bytes.Buffer + + g := global.Data{ + Config: config.File{}, + Env: config.Environment{}, + Output: &b, + } + g.APIClient, _ = mock.APIClient(mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + })("token", "endpoint", false) + + return &ftp.CreateCommand{ + Base: argparser.Base{ + Globals: &g, + }, + Manifest: manifest.Data{ + Flag: manifest.Flag{ + ServiceID: "123", + }, + }, + EndpointName: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "log"}, + Address: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "example.com"}, + Username: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "user"}, + Password: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "password"}, + ServiceVersion: argparser.OptionalServiceVersion{ + OptionalString: argparser.OptionalString{Value: "1"}, + }, + AutoClone: argparser.OptionalAutoClone{ + OptionalBool: argparser.OptionalBool{ + Optional: argparser.Optional{ + WasSet: true, + }, + Value: true, + }, + }, + } +} + +func createCommandAll() *ftp.CreateCommand { + var b bytes.Buffer + + g := global.Data{ + Config: config.File{}, + Env: config.Environment{}, + Output: &b, + } + g.APIClient, _ = mock.APIClient(mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + })("token", "endpoint", false) + + return &ftp.CreateCommand{ + Base: argparser.Base{ + Globals: &g, + }, + Manifest: manifest.Data{ + Flag: manifest.Flag{ + ServiceID: "123", + }, + }, + ServiceVersion: argparser.OptionalServiceVersion{ + OptionalString: argparser.OptionalString{Value: "1"}, + }, + AutoClone: argparser.OptionalAutoClone{ + OptionalBool: argparser.OptionalBool{ + Optional: argparser.Optional{ + WasSet: true, + }, + Value: true, + }, + }, + EndpointName: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "log"}, + Address: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "example.com"}, + Username: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "user"}, + Password: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "password"}, + Port: argparser.OptionalInt{Optional: argparser.Optional{WasSet: true}, Value: 22}, + Path: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "/logs"}, + Period: argparser.OptionalInt{Optional: argparser.Optional{WasSet: true}, Value: 3600}, + Format: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: `%h %l %u %t "%r" %>s %b`}, + FormatVersion: argparser.OptionalInt{Optional: argparser.Optional{WasSet: true}, Value: 2}, + TimestampFormat: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "%Y-%m-%dT%H:%M:%S.000"}, + ResponseCondition: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "Prevent default logging"}, + Placement: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "none"}, + CompressionCodec: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "zstd"}, + } +} + +func createCommandMissingServiceID() *ftp.CreateCommand { + res := createCommandAll() + res.Manifest = manifest.Data{} + return res +} + +func updateCommandNoUpdates() *ftp.UpdateCommand { + var b bytes.Buffer + + g := global.Data{ + Config: config.File{}, + Env: config.Environment{}, + Output: &b, + } + + return &ftp.UpdateCommand{ + Base: argparser.Base{ + Globals: &g, + }, + Manifest: manifest.Data{ + Flag: manifest.Flag{ + ServiceID: "123", + }, + }, + EndpointName: "log", + ServiceVersion: argparser.OptionalServiceVersion{ + OptionalString: argparser.OptionalString{Value: "1"}, + }, + AutoClone: argparser.OptionalAutoClone{ + OptionalBool: argparser.OptionalBool{ + Optional: argparser.Optional{ + WasSet: true, + }, + Value: true, + }, + }, + } +} + +func updateCommandAll() *ftp.UpdateCommand { + var b bytes.Buffer + + g := global.Data{ + Config: config.File{}, + Env: config.Environment{}, + Output: &b, + } + + return &ftp.UpdateCommand{ + Base: argparser.Base{ + Globals: &g, + }, + Manifest: manifest.Data{ + Flag: manifest.Flag{ + ServiceID: "123", + }, + }, + EndpointName: "log", + ServiceVersion: argparser.OptionalServiceVersion{ + OptionalString: argparser.OptionalString{Value: "1"}, + }, + AutoClone: argparser.OptionalAutoClone{ + OptionalBool: argparser.OptionalBool{ + Optional: argparser.Optional{ + WasSet: true, + }, + Value: true, + }, + }, + NewName: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new1"}, + Address: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new2"}, + Port: argparser.OptionalInt{Optional: argparser.Optional{WasSet: true}, Value: 23}, + Username: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new3"}, + Password: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new4"}, + PublicKey: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new10"}, + Path: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new5"}, + Period: argparser.OptionalInt{Optional: argparser.Optional{WasSet: true}, Value: 3601}, + GzipLevel: argparser.OptionalInt{Optional: argparser.Optional{WasSet: true}, Value: 0}, + Format: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new6"}, + FormatVersion: argparser.OptionalInt{Optional: argparser.Optional{WasSet: true}, Value: 3}, + ResponseCondition: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new7"}, + TimestampFormat: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new8"}, + Placement: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new9"}, + CompressionCodec: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new11"}, + } +} + +func updateCommandMissingServiceID() *ftp.UpdateCommand { + res := updateCommandAll() + res.Manifest = manifest.Data{} + return res +} diff --git a/pkg/commands/logging/ftp/list.go b/pkg/commands/logging/ftp/list.go new file mode 100644 index 000000000..86737de9d --- /dev/null +++ b/pkg/commands/logging/ftp/list.go @@ -0,0 +1,132 @@ +package ftp + +import ( + "fmt" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// ListCommand calls the Fastly API to list FTP logging endpoints. +type ListCommand struct { + argparser.Base + argparser.JSONOutput + + Input fastly.ListFTPsInput + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion +} + +// NewListCommand returns a usable command registered under the parent. +func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { + c := ListCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("list", "List FTP endpoints on a Fastly service version") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + return &c +} + +// Exec invokes the application logic for the command. +func (c *ListCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fsterr.ServiceVersion(serviceVersion), + }) + return err + } + + c.Input.ServiceID = serviceID + c.Input.ServiceVersion = fastly.ToValue(serviceVersion.Number) + + o, err := c.Globals.APIClient.ListFTPs(&c.Input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + if !c.Globals.Verbose() { + tw := text.NewTable(out) + tw.AddHeader("SERVICE", "VERSION", "NAME") + for _, ftp := range o { + tw.AddLine( + fastly.ToValue(ftp.ServiceID), + fastly.ToValue(ftp.ServiceVersion), + fastly.ToValue(ftp.Name), + ) + } + tw.Print() + return nil + } + + fmt.Fprintf(out, "Version: %d\n", c.Input.ServiceVersion) + for i, ftp := range o { + fmt.Fprintf(out, "\tFTP %d/%d\n", i+1, len(o)) + fmt.Fprintf(out, "\t\tService ID: %s\n", fastly.ToValue(ftp.ServiceID)) + fmt.Fprintf(out, "\t\tVersion: %d\n", fastly.ToValue(ftp.ServiceVersion)) + fmt.Fprintf(out, "\t\tName: %s\n", fastly.ToValue(ftp.Name)) + fmt.Fprintf(out, "\t\tAddress: %s\n", fastly.ToValue(ftp.Address)) + fmt.Fprintf(out, "\t\tPort: %d\n", fastly.ToValue(ftp.Port)) + fmt.Fprintf(out, "\t\tUsername: %s\n", fastly.ToValue(ftp.Username)) + fmt.Fprintf(out, "\t\tPassword: %s\n", fastly.ToValue(ftp.Password)) + fmt.Fprintf(out, "\t\tPublic key: %s\n", fastly.ToValue(ftp.PublicKey)) + fmt.Fprintf(out, "\t\tPath: %s\n", fastly.ToValue(ftp.Path)) + fmt.Fprintf(out, "\t\tPeriod: %d\n", fastly.ToValue(ftp.Period)) + fmt.Fprintf(out, "\t\tGZip level: %d\n", fastly.ToValue(ftp.GzipLevel)) + fmt.Fprintf(out, "\t\tFormat: %s\n", fastly.ToValue(ftp.Format)) + fmt.Fprintf(out, "\t\tFormat version: %d\n", fastly.ToValue(ftp.FormatVersion)) + fmt.Fprintf(out, "\t\tResponse condition: %s\n", fastly.ToValue(ftp.ResponseCondition)) + fmt.Fprintf(out, "\t\tTimestamp format: %s\n", fastly.ToValue(ftp.TimestampFormat)) + fmt.Fprintf(out, "\t\tPlacement: %s\n", fastly.ToValue(ftp.Placement)) + fmt.Fprintf(out, "\t\tCompression codec: %s\n", fastly.ToValue(ftp.CompressionCodec)) + } + fmt.Fprintln(out) + + return nil +} diff --git a/pkg/commands/logging/ftp/root.go b/pkg/commands/logging/ftp/root.go new file mode 100644 index 000000000..accc5d887 --- /dev/null +++ b/pkg/commands/logging/ftp/root.go @@ -0,0 +1,31 @@ +package ftp + +import ( + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" +) + +// RootCommand is the parent command for all subcommands in this package. +// It should be installed under the primary root command. +type RootCommand struct { + argparser.Base + // no flags +} + +// CommandName is the string to be used to invoke this command. +const CommandName = "ftp" + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent argparser.Registerer, g *global.Data) *RootCommand { + var c RootCommand + c.Globals = g + c.CmdClause = parent.Command(CommandName, "Manipulate Fastly service version FTP logging endpoints") + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { + panic("unreachable") +} diff --git a/pkg/commands/logging/ftp/update.go b/pkg/commands/logging/ftp/update.go new file mode 100644 index 000000000..7fc8ee943 --- /dev/null +++ b/pkg/commands/logging/ftp/update.go @@ -0,0 +1,212 @@ +package ftp + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/logging/common" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/text" +) + +// UpdateCommand calls the Fastly API to update an FTP logging endpoint. +type UpdateCommand struct { + argparser.Base + Manifest manifest.Data + + // Required. + EndpointName string // Can't shadow argparser.Base method Name(). + ServiceName argparser.OptionalServiceNameID + ServiceVersion argparser.OptionalServiceVersion + + // Optional. + AutoClone argparser.OptionalAutoClone + NewName argparser.OptionalString + Address argparser.OptionalString + Port argparser.OptionalInt + Username argparser.OptionalString + Password argparser.OptionalString + PublicKey argparser.OptionalString + Path argparser.OptionalString + Period argparser.OptionalInt + GzipLevel argparser.OptionalInt + Format argparser.OptionalString + FormatVersion argparser.OptionalInt + ResponseCondition argparser.OptionalString + TimestampFormat argparser.OptionalString + Placement argparser.OptionalString + CompressionCodec argparser.OptionalString +} + +// NewUpdateCommand returns a usable command registered under the parent. +func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { + c := UpdateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("update", "Update an FTP logging endpoint on a Fastly service version") + + // Required. + c.CmdClause.Flag("name", "The name of the FTP logging object").Short('n').Required().StringVar(&c.EndpointName) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.ServiceVersion.Value, + Required: true, + }) + + // Optional. + c.CmdClause.Flag("address", "An hostname or IPv4 address").Action(c.Address.Set).StringVar(&c.Address.Value) + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.AutoClone.Set, + Dst: &c.AutoClone.Value, + }) + common.CompressionCodec(c.CmdClause, &c.CompressionCodec) + common.Format(c.CmdClause, &c.Format) + common.FormatVersion(c.CmdClause, &c.FormatVersion) + common.GzipLevel(c.CmdClause, &c.GzipLevel) + c.CmdClause.Flag("new-name", "New name of the FTP logging object").Action(c.NewName.Set).StringVar(&c.NewName.Value) + c.CmdClause.Flag("password", "The password for the server (for anonymous use an email address)").Action(c.Password.Set).StringVar(&c.Password.Value) + c.CmdClause.Flag("path", "The path to upload log files to. If the path ends in / then it is treated as a directory").Action(c.Path.Set).StringVar(&c.Path.Value) + common.Period(c.CmdClause, &c.Period) + common.Placement(c.CmdClause, &c.Placement) + c.CmdClause.Flag("port", "The port number").Action(c.Port.Set).IntVar(&c.Port.Value) + common.PublicKey(c.CmdClause, &c.PublicKey) + common.ResponseCondition(c.CmdClause, &c.ResponseCondition) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.ServiceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.ServiceName.Value, + }) + common.TimestampFormat(c.CmdClause, &c.TimestampFormat) + c.CmdClause.Flag("username", "The username for the server (can be anonymous)").Action(c.Username.Set).StringVar(&c.Username.Value) + return &c +} + +// ConstructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *UpdateCommand) ConstructInput(serviceID string, serviceVersion int) (*fastly.UpdateFTPInput, error) { + input := fastly.UpdateFTPInput{ + ServiceID: serviceID, + ServiceVersion: serviceVersion, + Name: c.EndpointName, + } + + // Set new values if set by user. + if c.NewName.WasSet { + input.NewName = &c.NewName.Value + } + + if c.Address.WasSet { + input.Address = &c.Address.Value + } + + if c.Port.WasSet { + input.Port = &c.Port.Value + } + + if c.Username.WasSet { + input.Username = &c.Username.Value + } + + if c.Password.WasSet { + input.Password = &c.Password.Value + } + + if c.PublicKey.WasSet { + input.PublicKey = &c.PublicKey.Value + } + + if c.Path.WasSet { + input.Path = &c.Path.Value + } + + if c.Period.WasSet { + input.Period = &c.Period.Value + } + + if c.FormatVersion.WasSet { + input.FormatVersion = &c.FormatVersion.Value + } + + if c.GzipLevel.WasSet { + input.GzipLevel = &c.GzipLevel.Value + } + + if c.Format.WasSet { + input.Format = fastly.ToPointer(argparser.Content(c.Format.Value)) + } + + if c.ResponseCondition.WasSet { + input.ResponseCondition = &c.ResponseCondition.Value + } + + if c.TimestampFormat.WasSet { + input.TimestampFormat = &c.TimestampFormat.Value + } + + if c.Placement.WasSet { + input.Placement = &c.Placement.Value + } + + if c.CompressionCodec.WasSet { + input.CompressionCodec = &c.CompressionCodec.Value + } + + return &input, nil +} + +// Exec invokes the application logic for the command. +func (c *UpdateCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.AutoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.ServiceName, + ServiceVersionFlag: c.ServiceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + input, err := c.ConstructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + ftp, err := c.Globals.APIClient.UpdateFTP(input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + text.Success(out, + "Updated FTP logging endpoint %s (service %s version %d)", + fastly.ToValue(ftp.Name), + fastly.ToValue(ftp.ServiceID), + fastly.ToValue(ftp.ServiceVersion), + ) + return nil +} diff --git a/pkg/commands/logging/gcs/create.go b/pkg/commands/logging/gcs/create.go new file mode 100644 index 000000000..de9360efa --- /dev/null +++ b/pkg/commands/logging/gcs/create.go @@ -0,0 +1,206 @@ +package gcs + +import ( + "fmt" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/logging/common" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/text" +) + +// CreateCommand calls the Fastly API to create a GCS logging endpoint. +type CreateCommand struct { + argparser.Base + Manifest manifest.Data + + // Required. + ServiceName argparser.OptionalServiceNameID + ServiceVersion argparser.OptionalServiceVersion + + // Optional. + AccountName argparser.OptionalString + AutoClone argparser.OptionalAutoClone + Bucket argparser.OptionalString + CompressionCodec argparser.OptionalString + EndpointName argparser.OptionalString // Can't shadow argparser.Base method Name(). + Format argparser.OptionalString + FormatVersion argparser.OptionalInt + GzipLevel argparser.OptionalInt + MessageType argparser.OptionalString + Path argparser.OptionalString + Period argparser.OptionalInt + Placement argparser.OptionalString + ProjectID argparser.OptionalString + ResponseCondition argparser.OptionalString + SecretKey argparser.OptionalString + TimestampFormat argparser.OptionalString + User argparser.OptionalString +} + +// NewCreateCommand returns a usable command registered under the parent. +func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { + c := CreateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("create", "Create a GCS logging endpoint on a Fastly service version").Alias("add") + + // Required. + c.CmdClause.Flag("name", "The name of the GCS logging object. Used as a primary key for API access").Short('n').Action(c.EndpointName.Set).StringVar(&c.EndpointName.Value) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.ServiceVersion.Value, + Required: true, + }) + + // Optional. + common.AccountName(c.CmdClause, &c.AccountName) + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.AutoClone.Set, + Dst: &c.AutoClone.Value, + }) + c.CmdClause.Flag("bucket", "The bucket of the GCS bucket").Action(c.Bucket.Set).StringVar(&c.Bucket.Value) + common.CompressionCodec(c.CmdClause, &c.CompressionCodec) + common.Format(c.CmdClause, &c.Format) + common.FormatVersion(c.CmdClause, &c.FormatVersion) + common.GzipLevel(c.CmdClause, &c.GzipLevel) + common.MessageType(c.CmdClause, &c.MessageType) + common.Path(c.CmdClause, &c.Path) + common.Period(c.CmdClause, &c.Period) + common.Placement(c.CmdClause, &c.Placement) + c.CmdClause.Flag("project-id", "The google project ID").Action(c.ProjectID.Set).StringVar(&c.ProjectID.Value) + common.ResponseCondition(c.CmdClause, &c.ResponseCondition) + c.CmdClause.Flag("secret-key", "Your GCS account secret key. The private_key field in your service account authentication JSON").Action(c.SecretKey.Set).StringVar(&c.SecretKey.Value) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.ServiceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.ServiceName.Value, + }) + common.TimestampFormat(c.CmdClause, &c.TimestampFormat) + c.CmdClause.Flag("user", "Your GCS service account email address. The client_email field in your service account authentication JSON").Action(c.User.Set).StringVar(&c.User.Value) + return &c +} + +// ConstructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *CreateCommand) ConstructInput(serviceID string, serviceVersion int) (*fastly.CreateGCSInput, error) { + input := fastly.CreateGCSInput{ + ServiceID: serviceID, + ServiceVersion: serviceVersion, + } + + // The following blocks enforces the mutual exclusivity of the + // CompressionCodec and GzipLevel flags. + if c.CompressionCodec.WasSet && c.GzipLevel.WasSet { + return nil, fmt.Errorf("error parsing arguments: the --compression-codec flag is mutually exclusive with the --gzip-level flag") + } + + if c.AccountName.WasSet { + input.AccountName = &c.AccountName.Value + } + if c.Bucket.WasSet { + input.Bucket = &c.Bucket.Value + } + if c.CompressionCodec.WasSet { + input.CompressionCodec = &c.CompressionCodec.Value + } + if c.EndpointName.WasSet { + input.Name = &c.EndpointName.Value + } + if c.Format.WasSet { + input.Format = fastly.ToPointer(argparser.Content(c.Format.Value)) + } + if c.FormatVersion.WasSet { + input.FormatVersion = &c.FormatVersion.Value + } + if c.GzipLevel.WasSet { + input.GzipLevel = &c.GzipLevel.Value + } + if c.MessageType.WasSet { + input.MessageType = &c.MessageType.Value + } + if c.Path.WasSet { + input.Path = &c.Path.Value + } + if c.Period.WasSet { + input.Period = &c.Period.Value + } + if c.Placement.WasSet { + input.Placement = &c.Placement.Value + } + if c.ProjectID.WasSet { + input.ProjectID = &c.ProjectID.Value + } + if c.ResponseCondition.WasSet { + input.ResponseCondition = &c.ResponseCondition.Value + } + if c.SecretKey.WasSet { + input.SecretKey = &c.SecretKey.Value + } + if c.TimestampFormat.WasSet { + input.TimestampFormat = &c.TimestampFormat.Value + } + if c.User.WasSet { + input.User = &c.User.Value + } + + return &input, nil +} + +// Exec invokes the application logic for the command. +func (c *CreateCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.AutoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.ServiceName, + ServiceVersionFlag: c.ServiceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + input, err := c.ConstructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + d, err := c.Globals.APIClient.CreateGCS(input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + text.Success(out, + "Created GCS logging endpoint %s (service %s version %d)", + fastly.ToValue(d.Name), + fastly.ToValue(d.ServiceID), + fastly.ToValue(d.ServiceVersion), + ) + return nil +} diff --git a/pkg/commands/logging/gcs/delete.go b/pkg/commands/logging/gcs/delete.go new file mode 100644 index 000000000..7a39a1cfc --- /dev/null +++ b/pkg/commands/logging/gcs/delete.go @@ -0,0 +1,94 @@ +package gcs + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// DeleteCommand calls the Fastly API to delete a GCS logging endpoint. +type DeleteCommand struct { + argparser.Base + Input fastly.DeleteGCSInput + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion + autoClone argparser.OptionalAutoClone +} + +// NewDeleteCommand returns a usable command registered under the parent. +func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { + c := DeleteCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("delete", "Delete a GCS logging endpoint on a Fastly service version").Alias("remove") + + // Required. + c.CmdClause.Flag("name", "The name of the GCS logging object").Short('n').Required().StringVar(&c.Input.Name) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.autoClone.Set, + Dst: &c.autoClone.Value, + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + return &c +} + +// Exec invokes the application logic for the command. +func (c *DeleteCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.autoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + c.Input.ServiceID = serviceID + c.Input.ServiceVersion = fastly.ToValue(serviceVersion.Number) + + if err := c.Globals.APIClient.DeleteGCS(&c.Input); err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + text.Success(out, "Deleted GCS logging endpoint %s (service %s version %d)", c.Input.Name, c.Input.ServiceID, c.Input.ServiceVersion) + return nil +} diff --git a/pkg/commands/logging/gcs/describe.go b/pkg/commands/logging/gcs/describe.go new file mode 100644 index 000000000..4482b1e80 --- /dev/null +++ b/pkg/commands/logging/gcs/describe.go @@ -0,0 +1,119 @@ +package gcs + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// DescribeCommand calls the Fastly API to describe a GCS logging endpoint. +type DescribeCommand struct { + argparser.Base + argparser.JSONOutput + + Input fastly.GetGCSInput + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion +} + +// NewDescribeCommand returns a usable command registered under the parent. +func NewDescribeCommand(parent argparser.Registerer, g *global.Data) *DescribeCommand { + c := DescribeCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("describe", "Show detailed information about a GCS logging endpoint on a Fastly service version").Alias("get") + + // Required. + c.CmdClause.Flag("name", "The name of the GCS logging object").Short('n').Required().StringVar(&c.Input.Name) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + return &c +} + +// Exec invokes the application logic for the command. +func (c *DescribeCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fsterr.ServiceVersion(serviceVersion), + }) + return err + } + + c.Input.ServiceID = serviceID + c.Input.ServiceVersion = fastly.ToValue(serviceVersion.Number) + + o, err := c.Globals.APIClient.GetGCS(&c.Input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + lines := text.Lines{ + "Account name": fastly.ToValue(o.AccountName), + "Bucket": fastly.ToValue(o.Bucket), + "Compression codec": fastly.ToValue(o.CompressionCodec), + "Format version": fastly.ToValue(o.FormatVersion), + "Format": fastly.ToValue(o.Format), + "GZip level": fastly.ToValue(o.GzipLevel), + "Message type": fastly.ToValue(o.MessageType), + "Name": fastly.ToValue(o.Name), + "Path": fastly.ToValue(o.Path), + "Period": fastly.ToValue(o.Period), + "Project ID": fastly.ToValue(o.ProjectID), + "Placement": fastly.ToValue(o.Placement), + "Response condition": fastly.ToValue(o.ResponseCondition), + "Secret key": fastly.ToValue(o.SecretKey), + "Timestamp format": fastly.ToValue(o.TimestampFormat), + "User": fastly.ToValue(o.User), + "Version": fastly.ToValue(o.ServiceVersion), + } + if !c.Globals.Verbose() { + lines["Service ID"] = fastly.ToValue(o.ServiceID) + } + text.PrintLines(out, lines) + + return nil +} diff --git a/pkg/logging/gcs/doc.go b/pkg/commands/logging/gcs/doc.go similarity index 100% rename from pkg/logging/gcs/doc.go rename to pkg/commands/logging/gcs/doc.go diff --git a/pkg/commands/logging/gcs/gcs_integration_test.go b/pkg/commands/logging/gcs/gcs_integration_test.go new file mode 100644 index 000000000..24f24fcc3 --- /dev/null +++ b/pkg/commands/logging/gcs/gcs_integration_test.go @@ -0,0 +1,480 @@ +package gcs_test + +import ( + "bytes" + "errors" + "io" + "strings" + "testing" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/app" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" +) + +func TestGCSCreate(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging gcs create --service-id 123 --version 1 --name log --bucket log --user foo@example.com --secret-key foo --period 86400 --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + CreateGCSFn: createGCSOK, + }, + wantOutput: "Created GCS logging endpoint log (service 123 version 4)", + }, + { + args: args("logging gcs create --service-id 123 --version 1 --name log --bucket log --account-name service-account-id --project-id gcp-prj-id --period 86400 --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + CreateGCSFn: createGCSOK, + }, + wantOutput: "Created GCS logging endpoint log (service 123 version 4)", + }, + { + args: args("logging gcs create --service-id 123 --version 1 --name log --bucket log --user foo@example.com --secret-key foo --period 86400 --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + CreateGCSFn: createGCSError, + }, + wantError: errTest.Error(), + }, + { + args: args("logging gcs create --service-id 123 --version 1 --name log --bucket log --user foo@example.com --secret-key foo --period 86400 --compression-codec zstd --gzip-level 9 --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + }, + wantError: "error parsing arguments: the --compression-codec flag is mutually exclusive with the --gzip-level flag", + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertStringContains(t, stdout.String(), testcase.wantOutput) + }) + } +} + +func TestGCSList(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging gcs list --service-id 123 --version 1"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListGCSsFn: listGCSsOK, + }, + wantOutput: listGCSsShortOutput, + }, + { + args: args("logging gcs list --service-id 123 --version 1 --verbose"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListGCSsFn: listGCSsOK, + }, + wantOutput: listGCSsVerboseOutput, + }, + { + args: args("logging gcs list --service-id 123 --version 1 -v"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListGCSsFn: listGCSsOK, + }, + wantOutput: listGCSsVerboseOutput, + }, + { + args: args("logging gcs --verbose list --service-id 123 --version 1"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListGCSsFn: listGCSsOK, + }, + wantOutput: listGCSsVerboseOutput, + }, + { + args: args("logging -v gcs list --service-id 123 --version 1"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListGCSsFn: listGCSsOK, + }, + wantOutput: listGCSsVerboseOutput, + }, + { + args: args("logging gcs list --service-id 123 --version 1"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListGCSsFn: listGCSsError, + }, + wantError: errTest.Error(), + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertString(t, testcase.wantOutput, stdout.String()) + }) + } +} + +func TestGCSDescribe(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging gcs describe --service-id 123 --version 1"), + wantError: "error parsing arguments: required flag --name not provided", + }, + { + args: args("logging gcs describe --service-id 123 --version 1 --name logs"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + GetGCSFn: getGCSError, + }, + wantError: errTest.Error(), + }, + { + args: args("logging gcs describe --service-id 123 --version 1 --name logs"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + GetGCSFn: getGCSOK, + }, + wantOutput: describeGCSOutput, + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertString(t, testcase.wantOutput, stdout.String()) + }) + } +} + +func TestGCSUpdate(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging gcs update --service-id 123 --version 1 --new-name log"), + wantError: "error parsing arguments: required flag --name not provided", + }, + { + args: args("logging gcs update --service-id 123 --version 1 --name logs --new-name log --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + UpdateGCSFn: updateGCSError, + }, + wantError: errTest.Error(), + }, + { + args: args("logging gcs update --service-id 123 --version 1 --name logs --new-name log --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + UpdateGCSFn: updateGCSOK, + }, + wantOutput: "Updated GCS logging endpoint log (service 123 version 4)", + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertStringContains(t, stdout.String(), testcase.wantOutput) + }) + } +} + +func TestGCSDelete(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging gcs delete --service-id 123 --version 1"), + wantError: "error parsing arguments: required flag --name not provided", + }, + { + args: args("logging gcs delete --service-id 123 --version 1 --name logs --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + DeleteGCSFn: deleteGCSError, + }, + wantError: errTest.Error(), + }, + { + args: args("logging gcs delete --service-id 123 --version 1 --name logs --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + DeleteGCSFn: deleteGCSOK, + }, + wantOutput: "Deleted GCS logging endpoint logs (service 123 version 4)", + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertStringContains(t, stdout.String(), testcase.wantOutput) + }) + } +} + +var errTest = errors.New("fixture error") + +func createGCSOK(i *fastly.CreateGCSInput) (*fastly.GCS, error) { + return &fastly.GCS{ + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: i.Name, + }, nil +} + +func createGCSError(_ *fastly.CreateGCSInput) (*fastly.GCS, error) { + return nil, errTest +} + +func listGCSsOK(i *fastly.ListGCSsInput) ([]*fastly.GCS, error) { + return []*fastly.GCS{ + { + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("logs"), + Bucket: fastly.ToPointer("my-logs"), + User: fastly.ToPointer("foo@example.com"), + AccountName: fastly.ToPointer("me@fastly.com"), + SecretKey: fastly.ToPointer("-----BEGIN RSA PRIVATE KEY-----foo"), + Path: fastly.ToPointer("logs/"), + Period: fastly.ToPointer(3600), + GzipLevel: fastly.ToPointer(0), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + FormatVersion: fastly.ToPointer(2), + MessageType: fastly.ToPointer("classic"), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + TimestampFormat: fastly.ToPointer("%Y-%m-%dT%H:%M:%S.000"), + Placement: fastly.ToPointer("none"), + CompressionCodec: fastly.ToPointer("zstd"), + }, + { + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("analytics"), + Bucket: fastly.ToPointer("analytics"), + User: fastly.ToPointer("foo@example.com"), + AccountName: fastly.ToPointer("me@fastly.com"), + SecretKey: fastly.ToPointer("-----BEGIN RSA PRIVATE KEY-----foo"), + Path: fastly.ToPointer("logs/"), + Period: fastly.ToPointer(86400), + GzipLevel: fastly.ToPointer(0), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + FormatVersion: fastly.ToPointer(2), + MessageType: fastly.ToPointer("classic"), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + TimestampFormat: fastly.ToPointer("%Y-%m-%dT%H:%M:%S.000"), + Placement: fastly.ToPointer("none"), + CompressionCodec: fastly.ToPointer("zstd"), + }, + }, nil +} + +func listGCSsError(_ *fastly.ListGCSsInput) ([]*fastly.GCS, error) { + return nil, errTest +} + +var listGCSsShortOutput = strings.TrimSpace(` +SERVICE VERSION NAME +123 1 logs +123 1 analytics +`) + "\n" + +var listGCSsVerboseOutput = strings.TrimSpace(` +Fastly API endpoint: https://api.fastly.com +Fastly API token provided via config file (profile: user) + +Service ID (via --service-id): 123 + +Version: 1 + GCS 1/2 + Service ID: 123 + Version: 1 + Name: logs + Bucket: my-logs + User: foo@example.com + Account name: me@fastly.com + Secret key: -----BEGIN RSA PRIVATE KEY-----foo + Path: logs/ + Period: 3600 + GZip level: 0 + Format: %h %l %u %t "%r" %>s %b + Format version: 2 + Response condition: Prevent default logging + Message type: classic + Timestamp format: %Y-%m-%dT%H:%M:%S.000 + Placement: none + Compression codec: zstd + GCS 2/2 + Service ID: 123 + Version: 1 + Name: analytics + Bucket: analytics + User: foo@example.com + Account name: me@fastly.com + Secret key: -----BEGIN RSA PRIVATE KEY-----foo + Path: logs/ + Period: 86400 + GZip level: 0 + Format: %h %l %u %t "%r" %>s %b + Format version: 2 + Response condition: Prevent default logging + Message type: classic + Timestamp format: %Y-%m-%dT%H:%M:%S.000 + Placement: none + Compression codec: zstd +`) + "\n\n" + +func getGCSOK(i *fastly.GetGCSInput) (*fastly.GCS, error) { + return &fastly.GCS{ + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("logs"), + Bucket: fastly.ToPointer("my-logs"), + User: fastly.ToPointer("foo@example.com"), + SecretKey: fastly.ToPointer("-----BEGIN RSA PRIVATE KEY-----foo"), + AccountName: fastly.ToPointer("me@fastly.com"), + Path: fastly.ToPointer("logs/"), + Period: fastly.ToPointer(3600), + GzipLevel: fastly.ToPointer(0), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + FormatVersion: fastly.ToPointer(2), + MessageType: fastly.ToPointer("classic"), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + TimestampFormat: fastly.ToPointer("%Y-%m-%dT%H:%M:%S.000"), + Placement: fastly.ToPointer("none"), + CompressionCodec: fastly.ToPointer("zstd"), + }, nil +} + +func getGCSError(_ *fastly.GetGCSInput) (*fastly.GCS, error) { + return nil, errTest +} + +var describeGCSOutput = "\n" + strings.TrimSpace(` +Account name: me@fastly.com +Bucket: my-logs +Compression codec: zstd +Format: %h %l %u %t "%r" %>s %b +Format version: 2 +GZip level: 0 +Message type: classic +Name: logs +Path: logs/ +Period: 3600 +Placement: none +Project ID: +Response condition: Prevent default logging +Secret key: -----BEGIN RSA PRIVATE KEY-----foo +Service ID: 123 +Timestamp format: %Y-%m-%dT%H:%M:%S.000 +User: foo@example.com +Version: 1 +`) + "\n" + +func updateGCSOK(i *fastly.UpdateGCSInput) (*fastly.GCS, error) { + return &fastly.GCS{ + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("log"), + Bucket: fastly.ToPointer("logs"), + User: fastly.ToPointer("foo@example.com"), + SecretKey: fastly.ToPointer("-----BEGIN RSA PRIVATE KEY-----foo"), + Path: fastly.ToPointer("logs/"), + Period: fastly.ToPointer(3600), + GzipLevel: fastly.ToPointer(0), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + FormatVersion: fastly.ToPointer(2), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + MessageType: fastly.ToPointer("classic"), + TimestampFormat: fastly.ToPointer("%Y-%m-%dT%H:%M:%S.000"), + Placement: fastly.ToPointer("none"), + CompressionCodec: fastly.ToPointer("zstd"), + }, nil +} + +func updateGCSError(_ *fastly.UpdateGCSInput) (*fastly.GCS, error) { + return nil, errTest +} + +func deleteGCSOK(_ *fastly.DeleteGCSInput) error { + return nil +} + +func deleteGCSError(_ *fastly.DeleteGCSInput) error { + return errTest +} diff --git a/pkg/commands/logging/gcs/gcs_test.go b/pkg/commands/logging/gcs/gcs_test.go new file mode 100644 index 000000000..e9ddf4f0a --- /dev/null +++ b/pkg/commands/logging/gcs/gcs_test.go @@ -0,0 +1,370 @@ +package gcs_test + +import ( + "bytes" + "testing" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/logging/gcs" + "github.com/fastly/cli/pkg/config" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" +) + +func TestCreateGCSInput(t *testing.T) { + for _, testcase := range []struct { + name string + cmd *gcs.CreateCommand + want *fastly.CreateGCSInput + wantError string + }{ + { + name: "required values set flag serviceID", + cmd: createCommandRequired(), + want: &fastly.CreateGCSInput{ + ServiceID: "123", + ServiceVersion: 4, + Name: fastly.ToPointer("log"), + Bucket: fastly.ToPointer("bucket"), + User: fastly.ToPointer("user"), + SecretKey: fastly.ToPointer("-----BEGIN PRIVATE KEY-----foo"), + }, + }, + { + name: "all values set flag serviceID", + cmd: createCommandAll(), + want: &fastly.CreateGCSInput{ + ServiceID: "123", + ServiceVersion: 4, + Name: fastly.ToPointer("log"), + Bucket: fastly.ToPointer("bucket"), + User: fastly.ToPointer("user"), + SecretKey: fastly.ToPointer("-----BEGIN PRIVATE KEY-----foo"), + Path: fastly.ToPointer("/logs"), + Period: fastly.ToPointer(3600), + FormatVersion: fastly.ToPointer(2), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + MessageType: fastly.ToPointer("classic"), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + TimestampFormat: fastly.ToPointer("%Y-%m-%dT%H:%M:%S.000"), + Placement: fastly.ToPointer("none"), + CompressionCodec: fastly.ToPointer("zstd"), + }, + }, + { + name: "error missing serviceID", + cmd: createCommandMissingServiceID(), + want: nil, + wantError: errors.ErrNoServiceID.Error(), + }, + } { + t.Run(testcase.name, func(t *testing.T) { + var bs []byte + out := bytes.NewBuffer(bs) + verboseMode := true + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + AutoCloneFlag: testcase.cmd.AutoClone, + APIClient: testcase.cmd.Globals.APIClient, + Manifest: testcase.cmd.Manifest, + Out: out, + ServiceVersionFlag: testcase.cmd.ServiceVersion, + VerboseMode: verboseMode, + }) + + switch { + case err != nil && testcase.wantError == "": + t.Fatalf("unexpected error getting service details: %v", err) + return + case err != nil && testcase.wantError != "": + testutil.AssertErrorContains(t, err, testcase.wantError) + return + case err == nil && testcase.wantError != "": + t.Fatalf("expected error, have nil (service details: %s, %d)", serviceID, serviceVersion.Number) + case err == nil && testcase.wantError == "": + have, err := testcase.cmd.ConstructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertEqual(t, testcase.want, have) + } + }) + } +} + +func TestUpdateGCSInput(t *testing.T) { + scenarios := []struct { + name string + cmd *gcs.UpdateCommand + api mock.API + want *fastly.UpdateGCSInput + wantError string + }{ + { + name: "no updates", + cmd: updateCommandNoUpdates(), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + GetGCSFn: getGCSOK, + }, + want: &fastly.UpdateGCSInput{ + ServiceID: "123", + ServiceVersion: 4, + Name: "log", + }, + }, + { + name: "all values set flag serviceID", + cmd: updateCommandAll(), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + GetGCSFn: getGCSOK, + }, + want: &fastly.UpdateGCSInput{ + ServiceID: "123", + ServiceVersion: 4, + Name: "log", + NewName: fastly.ToPointer("new1"), + Bucket: fastly.ToPointer("new2"), + User: fastly.ToPointer("new3"), + SecretKey: fastly.ToPointer("new4"), + Path: fastly.ToPointer("new5"), + Period: fastly.ToPointer(3601), + FormatVersion: fastly.ToPointer(3), + GzipLevel: fastly.ToPointer(0), + Format: fastly.ToPointer("new6"), + ResponseCondition: fastly.ToPointer("new7"), + TimestampFormat: fastly.ToPointer("new8"), + Placement: fastly.ToPointer("new9"), + MessageType: fastly.ToPointer("new10"), + CompressionCodec: fastly.ToPointer("new11"), + }, + }, + { + name: "error missing serviceID", + cmd: updateCommandMissingServiceID(), + want: nil, + wantError: errors.ErrNoServiceID.Error(), + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(testcase.name, func(t *testing.T) { + testcase.cmd.Globals.APIClient = testcase.api + + var bs []byte + out := bytes.NewBuffer(bs) + verboseMode := true + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + AutoCloneFlag: testcase.cmd.AutoClone, + APIClient: testcase.api, + Manifest: testcase.cmd.Manifest, + Out: out, + ServiceVersionFlag: testcase.cmd.ServiceVersion, + VerboseMode: verboseMode, + }) + + switch { + case err != nil && testcase.wantError == "": + t.Fatalf("unexpected error getting service details: %v", err) + return + case err != nil && testcase.wantError != "": + testutil.AssertErrorContains(t, err, testcase.wantError) + return + case err == nil && testcase.wantError != "": + t.Fatalf("expected error, have nil (service details: %s, %d)", serviceID, serviceVersion.Number) + case err == nil && testcase.wantError == "": + have, err := testcase.cmd.ConstructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertEqual(t, testcase.want, have) + } + }) + } +} + +func createCommandRequired() *gcs.CreateCommand { + var b bytes.Buffer + + g := global.Data{ + Config: config.File{}, + Env: config.Environment{}, + Output: &b, + } + g.APIClient, _ = mock.APIClient(mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + })("token", "endpoint", false) + + return &gcs.CreateCommand{ + Base: argparser.Base{ + Globals: &g, + }, + Manifest: manifest.Data{ + Flag: manifest.Flag{ + ServiceID: "123", + }, + }, + ServiceVersion: argparser.OptionalServiceVersion{ + OptionalString: argparser.OptionalString{Value: "1"}, + }, + AutoClone: argparser.OptionalAutoClone{ + OptionalBool: argparser.OptionalBool{ + Optional: argparser.Optional{ + WasSet: true, + }, + Value: true, + }, + }, + EndpointName: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "log"}, + Bucket: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "bucket"}, + User: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "user"}, + SecretKey: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "-----BEGIN PRIVATE KEY-----foo"}, + } +} + +func createCommandAll() *gcs.CreateCommand { + var b bytes.Buffer + + g := global.Data{ + Config: config.File{}, + Env: config.Environment{}, + Output: &b, + } + g.APIClient, _ = mock.APIClient(mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + })("token", "endpoint", false) + + return &gcs.CreateCommand{ + Base: argparser.Base{ + Globals: &g, + }, + Manifest: manifest.Data{ + Flag: manifest.Flag{ + ServiceID: "123", + }, + }, + ServiceVersion: argparser.OptionalServiceVersion{ + OptionalString: argparser.OptionalString{Value: "1"}, + }, + AutoClone: argparser.OptionalAutoClone{ + OptionalBool: argparser.OptionalBool{ + Optional: argparser.Optional{ + WasSet: true, + }, + Value: true, + }, + }, + EndpointName: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "log"}, + Bucket: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "bucket"}, + User: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "user"}, + SecretKey: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "-----BEGIN PRIVATE KEY-----foo"}, + Path: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "/logs"}, + Period: argparser.OptionalInt{Optional: argparser.Optional{WasSet: true}, Value: 3600}, + Format: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: `%h %l %u %t "%r" %>s %b`}, + FormatVersion: argparser.OptionalInt{Optional: argparser.Optional{WasSet: true}, Value: 2}, + TimestampFormat: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "%Y-%m-%dT%H:%M:%S.000"}, + MessageType: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "classic"}, + ResponseCondition: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "Prevent default logging"}, + Placement: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "none"}, + CompressionCodec: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "zstd"}, + } +} + +func createCommandMissingServiceID() *gcs.CreateCommand { + res := createCommandAll() + res.Manifest = manifest.Data{} + return res +} + +func updateCommandNoUpdates() *gcs.UpdateCommand { + var b bytes.Buffer + + g := global.Data{ + Config: config.File{}, + Env: config.Environment{}, + Output: &b, + } + + return &gcs.UpdateCommand{ + Base: argparser.Base{ + Globals: &g, + }, + Manifest: manifest.Data{ + Flag: manifest.Flag{ + ServiceID: "123", + }, + }, + EndpointName: "log", + ServiceVersion: argparser.OptionalServiceVersion{ + OptionalString: argparser.OptionalString{Value: "1"}, + }, + AutoClone: argparser.OptionalAutoClone{ + OptionalBool: argparser.OptionalBool{ + Optional: argparser.Optional{ + WasSet: true, + }, + Value: true, + }, + }, + } +} + +func updateCommandAll() *gcs.UpdateCommand { + var b bytes.Buffer + + g := global.Data{ + Config: config.File{}, + Env: config.Environment{}, + Output: &b, + } + + return &gcs.UpdateCommand{ + Base: argparser.Base{ + Globals: &g, + }, + Manifest: manifest.Data{ + Flag: manifest.Flag{ + ServiceID: "123", + }, + }, + EndpointName: "log", + ServiceVersion: argparser.OptionalServiceVersion{ + OptionalString: argparser.OptionalString{Value: "1"}, + }, + AutoClone: argparser.OptionalAutoClone{ + OptionalBool: argparser.OptionalBool{ + Optional: argparser.Optional{ + WasSet: true, + }, + Value: true, + }, + }, + NewName: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new1"}, + Bucket: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new2"}, + User: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new3"}, + SecretKey: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new4"}, + Path: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new5"}, + Period: argparser.OptionalInt{Optional: argparser.Optional{WasSet: true}, Value: 3601}, + GzipLevel: argparser.OptionalInt{Optional: argparser.Optional{WasSet: true}, Value: 0}, + Format: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new6"}, + FormatVersion: argparser.OptionalInt{Optional: argparser.Optional{WasSet: true}, Value: 3}, + ResponseCondition: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new7"}, + TimestampFormat: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new8"}, + Placement: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new9"}, + MessageType: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new10"}, + CompressionCodec: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new11"}, + } +} + +func updateCommandMissingServiceID() *gcs.UpdateCommand { + res := updateCommandAll() + res.Manifest = manifest.Data{} + return res +} diff --git a/pkg/commands/logging/gcs/list.go b/pkg/commands/logging/gcs/list.go new file mode 100644 index 000000000..190607d8f --- /dev/null +++ b/pkg/commands/logging/gcs/list.go @@ -0,0 +1,132 @@ +package gcs + +import ( + "fmt" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// ListCommand calls the Fastly API to list GCS logging endpoints. +type ListCommand struct { + argparser.Base + argparser.JSONOutput + + Input fastly.ListGCSsInput + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion +} + +// NewListCommand returns a usable command registered under the parent. +func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { + c := ListCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("list", "List GCS endpoints on a Fastly service version") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + return &c +} + +// Exec invokes the application logic for the command. +func (c *ListCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fsterr.ServiceVersion(serviceVersion), + }) + return err + } + + c.Input.ServiceID = serviceID + c.Input.ServiceVersion = fastly.ToValue(serviceVersion.Number) + + o, err := c.Globals.APIClient.ListGCSs(&c.Input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + if !c.Globals.Verbose() { + tw := text.NewTable(out) + tw.AddHeader("SERVICE", "VERSION", "NAME") + for _, gcs := range o { + tw.AddLine( + fastly.ToValue(gcs.ServiceID), + fastly.ToValue(gcs.ServiceVersion), + fastly.ToValue(gcs.Name), + ) + } + tw.Print() + return nil + } + + fmt.Fprintf(out, "Version: %d\n", c.Input.ServiceVersion) + for i, gcs := range o { + fmt.Fprintf(out, "\tGCS %d/%d\n", i+1, len(o)) + fmt.Fprintf(out, "\t\tService ID: %s\n", fastly.ToValue(gcs.ServiceID)) + fmt.Fprintf(out, "\t\tVersion: %d\n", fastly.ToValue(gcs.ServiceVersion)) + fmt.Fprintf(out, "\t\tName: %s\n", fastly.ToValue(gcs.Name)) + fmt.Fprintf(out, "\t\tBucket: %s\n", fastly.ToValue(gcs.Bucket)) + fmt.Fprintf(out, "\t\tUser: %s\n", fastly.ToValue(gcs.User)) + fmt.Fprintf(out, "\t\tAccount name: %s\n", fastly.ToValue(gcs.AccountName)) + fmt.Fprintf(out, "\t\tSecret key: %s\n", fastly.ToValue(gcs.SecretKey)) + fmt.Fprintf(out, "\t\tPath: %s\n", fastly.ToValue(gcs.Path)) + fmt.Fprintf(out, "\t\tPeriod: %d\n", fastly.ToValue(gcs.Period)) + fmt.Fprintf(out, "\t\tGZip level: %d\n", fastly.ToValue(gcs.GzipLevel)) + fmt.Fprintf(out, "\t\tFormat: %s\n", fastly.ToValue(gcs.Format)) + fmt.Fprintf(out, "\t\tFormat version: %d\n", fastly.ToValue(gcs.FormatVersion)) + fmt.Fprintf(out, "\t\tResponse condition: %s\n", fastly.ToValue(gcs.ResponseCondition)) + fmt.Fprintf(out, "\t\tMessage type: %s\n", fastly.ToValue(gcs.MessageType)) + fmt.Fprintf(out, "\t\tTimestamp format: %s\n", fastly.ToValue(gcs.TimestampFormat)) + fmt.Fprintf(out, "\t\tPlacement: %s\n", fastly.ToValue(gcs.Placement)) + fmt.Fprintf(out, "\t\tCompression codec: %s\n", fastly.ToValue(gcs.CompressionCodec)) + } + fmt.Fprintln(out) + + return nil +} diff --git a/pkg/commands/logging/gcs/root.go b/pkg/commands/logging/gcs/root.go new file mode 100644 index 000000000..2faa0f0b2 --- /dev/null +++ b/pkg/commands/logging/gcs/root.go @@ -0,0 +1,31 @@ +package gcs + +import ( + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" +) + +// RootCommand is the parent command for all subcommands in this package. +// It should be installed under the primary root command. +type RootCommand struct { + argparser.Base + // no flags +} + +// CommandName is the string to be used to invoke this command. +const CommandName = "gcs" + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent argparser.Registerer, g *global.Data) *RootCommand { + var c RootCommand + c.Globals = g + c.CmdClause = parent.Command(CommandName, "Manipulate Fastly service version GCS logging endpoints") + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { + panic("unreachable") +} diff --git a/pkg/commands/logging/gcs/update.go b/pkg/commands/logging/gcs/update.go new file mode 100644 index 000000000..1bb53beec --- /dev/null +++ b/pkg/commands/logging/gcs/update.go @@ -0,0 +1,202 @@ +package gcs + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/logging/common" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/text" +) + +// UpdateCommand calls the Fastly API to update a GCS logging endpoint. +type UpdateCommand struct { + argparser.Base + Manifest manifest.Data + + // Required. + EndpointName string // Can't shadow argparser.Base method Name(). + ServiceName argparser.OptionalServiceNameID + ServiceVersion argparser.OptionalServiceVersion + + // Optional. + AccountName argparser.OptionalString + AutoClone argparser.OptionalAutoClone + Bucket argparser.OptionalString + CompressionCodec argparser.OptionalString + Format argparser.OptionalString + FormatVersion argparser.OptionalInt + GzipLevel argparser.OptionalInt + MessageType argparser.OptionalString + NewName argparser.OptionalString + Path argparser.OptionalString + Period argparser.OptionalInt + Placement argparser.OptionalString + ProjectID argparser.OptionalString + ResponseCondition argparser.OptionalString + SecretKey argparser.OptionalString + TimestampFormat argparser.OptionalString + User argparser.OptionalString +} + +// NewUpdateCommand returns a usable command registered under the parent. +func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { + c := UpdateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("update", "Update a GCS logging endpoint on a Fastly service version") + + // Required. + c.CmdClause.Flag("name", "The name of the GCS logging object").Short('n').Required().StringVar(&c.EndpointName) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.ServiceVersion.Value, + Required: true, + }) + + // Optional. + common.AccountName(c.CmdClause, &c.AccountName) + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.AutoClone.Set, + Dst: &c.AutoClone.Value, + }) + c.CmdClause.Flag("bucket", "The bucket of the GCS bucket").Action(c.Bucket.Set).StringVar(&c.Bucket.Value) + common.CompressionCodec(c.CmdClause, &c.CompressionCodec) + common.Format(c.CmdClause, &c.Format) + common.FormatVersion(c.CmdClause, &c.FormatVersion) + common.GzipLevel(c.CmdClause, &c.GzipLevel) + common.MessageType(c.CmdClause, &c.MessageType) + c.CmdClause.Flag("new-name", "New name of the GCS logging object").Action(c.NewName.Set).StringVar(&c.NewName.Value) + c.CmdClause.Flag("path", "The path to upload logs to (default '/')").Action(c.Path.Set).StringVar(&c.Path.Value) + common.Period(c.CmdClause, &c.Period) + common.Placement(c.CmdClause, &c.Placement) + c.CmdClause.Flag("project-id", "The google project ID").Action(c.ProjectID.Set).StringVar(&c.ProjectID.Value) + common.ResponseCondition(c.CmdClause, &c.ResponseCondition) + c.CmdClause.Flag("secret-key", "Your GCS account secret key. The private_key field in your service account authentication JSON").Action(c.SecretKey.Set).StringVar(&c.SecretKey.Value) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.ServiceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.ServiceName.Value, + }) + c.CmdClause.Flag("user", "Your GCS service account email address. The client_email field in your service account authentication JSON").Action(c.User.Set).StringVar(&c.User.Value) + common.TimestampFormat(c.CmdClause, &c.TimestampFormat) + return &c +} + +// ConstructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *UpdateCommand) ConstructInput(serviceID string, serviceVersion int) (*fastly.UpdateGCSInput, error) { + input := fastly.UpdateGCSInput{ + ServiceID: serviceID, + ServiceVersion: serviceVersion, + Name: c.EndpointName, + } + + if c.AccountName.WasSet { + input.AccountName = &c.AccountName.Value + } + if c.Bucket.WasSet { + input.Bucket = &c.Bucket.Value + } + if c.CompressionCodec.WasSet { + input.CompressionCodec = &c.CompressionCodec.Value + } + if c.Format.WasSet { + input.Format = fastly.ToPointer(argparser.Content(c.Format.Value)) + } + if c.FormatVersion.WasSet { + input.FormatVersion = &c.FormatVersion.Value + } + if c.GzipLevel.WasSet { + input.GzipLevel = &c.GzipLevel.Value + } + if c.MessageType.WasSet { + input.MessageType = &c.MessageType.Value + } + if c.NewName.WasSet { + input.NewName = &c.NewName.Value + } + if c.Path.WasSet { + input.Path = &c.Path.Value + } + if c.Period.WasSet { + input.Period = &c.Period.Value + } + if c.Placement.WasSet { + input.Placement = &c.Placement.Value + } + if c.ProjectID.WasSet { + input.ProjectID = &c.ProjectID.Value + } + if c.ResponseCondition.WasSet { + input.ResponseCondition = &c.ResponseCondition.Value + } + if c.SecretKey.WasSet { + input.SecretKey = &c.SecretKey.Value + } + if c.TimestampFormat.WasSet { + input.TimestampFormat = &c.TimestampFormat.Value + } + if c.User.WasSet { + input.User = &c.User.Value + } + + return &input, nil +} + +// Exec invokes the application logic for the command. +func (c *UpdateCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.AutoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.ServiceName, + ServiceVersionFlag: c.ServiceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + input, err := c.ConstructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + gcs, err := c.Globals.APIClient.UpdateGCS(input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + text.Success(out, + "Updated GCS logging endpoint %s (service %s version %d)", + fastly.ToValue(gcs.Name), + fastly.ToValue(gcs.ServiceID), + fastly.ToValue(gcs.ServiceVersion), + ) + return nil +} diff --git a/pkg/commands/logging/googlepubsub/create.go b/pkg/commands/logging/googlepubsub/create.go new file mode 100644 index 000000000..3c33bcf30 --- /dev/null +++ b/pkg/commands/logging/googlepubsub/create.go @@ -0,0 +1,169 @@ +package googlepubsub + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/logging/common" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/text" +) + +// CreateCommand calls the Fastly API to create a Google Cloud Pub/Sub logging endpoint. +type CreateCommand struct { + argparser.Base + Manifest manifest.Data + + // Required. + ServiceName argparser.OptionalServiceNameID + ServiceVersion argparser.OptionalServiceVersion + + // Optional. + AccountName argparser.OptionalString + AutoClone argparser.OptionalAutoClone + EndpointName argparser.OptionalString // Can't shadow argparser.Base method Name(). + Format argparser.OptionalString + FormatVersion argparser.OptionalInt + Placement argparser.OptionalString + ProjectID argparser.OptionalString + ResponseCondition argparser.OptionalString + SecretKey argparser.OptionalString + Topic argparser.OptionalString + User argparser.OptionalString +} + +// NewCreateCommand returns a usable command registered under the parent. +func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { + c := CreateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("create", "Create a Google Cloud Pub/Sub logging endpoint on a Fastly service version").Alias("add") + + // Required. + c.CmdClause.Flag("name", "The name of the Google Cloud Pub/Sub logging object. Used as a primary key for API access").Short('n').Action(c.EndpointName.Set).StringVar(&c.EndpointName.Value) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.ServiceVersion.Value, + Required: true, + }) + + // Optional. + common.AccountName(c.CmdClause, &c.AccountName) + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.AutoClone.Set, + Dst: &c.AutoClone.Value, + }) + common.Format(c.CmdClause, &c.Format) + common.FormatVersion(c.CmdClause, &c.FormatVersion) + common.Placement(c.CmdClause, &c.Placement) + c.CmdClause.Flag("project-id", "The ID of your Google Cloud Platform project").Action(c.ProjectID.Set).StringVar(&c.ProjectID.Value) + common.ResponseCondition(c.CmdClause, &c.ResponseCondition) + c.CmdClause.Flag("secret-key", "Your Google Cloud Platform account secret key. The private_key field in your service account authentication JSON").Action(c.SecretKey.Set).StringVar(&c.SecretKey.Value) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.ServiceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.ServiceName.Value, + }) + c.CmdClause.Flag("topic", "The Google Cloud Pub/Sub topic to which logs will be published").Action(c.Topic.Set).StringVar(&c.Topic.Value) + c.CmdClause.Flag("user", "Your Google Cloud Platform service account email address. The client_email field in your service account authentication JSON").Action(c.User.Set).StringVar(&c.User.Value) + return &c +} + +// ConstructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *CreateCommand) ConstructInput(serviceID string, serviceVersion int) (*fastly.CreatePubsubInput, error) { + input := fastly.CreatePubsubInput{ + ServiceID: serviceID, + ServiceVersion: serviceVersion, + } + + if c.AccountName.WasSet { + input.AccountName = &c.AccountName.Value + } + if c.EndpointName.WasSet { + input.Name = &c.EndpointName.Value + } + if c.Format.WasSet { + input.Format = fastly.ToPointer(argparser.Content(c.Format.Value)) + } + if c.FormatVersion.WasSet { + input.FormatVersion = &c.FormatVersion.Value + } + if c.Placement.WasSet { + input.Placement = &c.Placement.Value + } + if c.ProjectID.WasSet { + input.ProjectID = &c.ProjectID.Value + } + if c.ResponseCondition.WasSet { + input.ResponseCondition = &c.ResponseCondition.Value + } + if c.SecretKey.WasSet { + input.SecretKey = &c.SecretKey.Value + } + if c.Topic.WasSet { + input.Topic = &c.Topic.Value + } + if c.User.WasSet { + input.User = &c.User.Value + } + + return &input, nil +} + +// Exec invokes the application logic for the command. +func (c *CreateCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.AutoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.ServiceName, + ServiceVersionFlag: c.ServiceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + input, err := c.ConstructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + d, err := c.Globals.APIClient.CreatePubsub(input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + text.Success(out, + "Created Google Cloud Pub/Sub logging endpoint %s (service %s version %d)", + fastly.ToValue(d.Name), + fastly.ToValue(d.ServiceID), + fastly.ToValue(d.ServiceVersion), + ) + return nil +} diff --git a/pkg/commands/logging/googlepubsub/delete.go b/pkg/commands/logging/googlepubsub/delete.go new file mode 100644 index 000000000..09f185924 --- /dev/null +++ b/pkg/commands/logging/googlepubsub/delete.go @@ -0,0 +1,94 @@ +package googlepubsub + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// DeleteCommand calls the Fastly API to delete a Google Cloud Pub/Sub logging endpoint. +type DeleteCommand struct { + argparser.Base + Input fastly.DeletePubsubInput + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion + autoClone argparser.OptionalAutoClone +} + +// NewDeleteCommand returns a usable command registered under the parent. +func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { + c := DeleteCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("delete", "Delete a Google Cloud Pub/Sub logging endpoint on a Fastly service version").Alias("remove") + + // Required. + c.CmdClause.Flag("name", "The name of the Google Cloud Pub/Sub logging object").Short('n').Required().StringVar(&c.Input.Name) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.autoClone.Set, + Dst: &c.autoClone.Value, + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + return &c +} + +// Exec invokes the application logic for the command. +func (c *DeleteCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.autoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + c.Input.ServiceID = serviceID + c.Input.ServiceVersion = fastly.ToValue(serviceVersion.Number) + + if err := c.Globals.APIClient.DeletePubsub(&c.Input); err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + text.Success(out, "Deleted Google Cloud Pub/Sub logging endpoint %s (service %s version %d)", c.Input.Name, c.Input.ServiceID, c.Input.ServiceVersion) + return nil +} diff --git a/pkg/commands/logging/googlepubsub/describe.go b/pkg/commands/logging/googlepubsub/describe.go new file mode 100644 index 000000000..c1f321756 --- /dev/null +++ b/pkg/commands/logging/googlepubsub/describe.go @@ -0,0 +1,113 @@ +package googlepubsub + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// DescribeCommand calls the Fastly API to describe a Google Cloud Pub/Sub logging endpoint. +type DescribeCommand struct { + argparser.Base + argparser.JSONOutput + + Input fastly.GetPubsubInput + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion +} + +// NewDescribeCommand returns a usable command registered under the parent. +func NewDescribeCommand(parent argparser.Registerer, g *global.Data) *DescribeCommand { + c := DescribeCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("describe", "Show detailed information about a Google Cloud Pub/Sub logging endpoint on a Fastly service version").Alias("get") + + // Required. + c.CmdClause.Flag("name", "The name of the Google Cloud Pub/Sub logging object").Short('n').Required().StringVar(&c.Input.Name) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + return &c +} + +// Exec invokes the application logic for the command. +func (c *DescribeCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fsterr.ServiceVersion(serviceVersion), + }) + return err + } + + c.Input.ServiceID = serviceID + c.Input.ServiceVersion = fastly.ToValue(serviceVersion.Number) + + o, err := c.Globals.APIClient.GetPubsub(&c.Input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + lines := text.Lines{ + "Account name": fastly.ToValue(o.AccountName), + "Format version": fastly.ToValue(o.FormatVersion), + "Format": fastly.ToValue(o.Format), + "Name": fastly.ToValue(o.Name), + "Placement": fastly.ToValue(o.Placement), + "Project ID": fastly.ToValue(o.ProjectID), + "Response condition": fastly.ToValue(o.ResponseCondition), + "Secret key": fastly.ToValue(o.SecretKey), + "Topic": fastly.ToValue(o.Topic), + "User": fastly.ToValue(o.User), + "Version": fastly.ToValue(o.ServiceVersion), + } + if !c.Globals.Verbose() { + lines["Service ID"] = fastly.ToValue(o.ServiceID) + } + text.PrintLines(out, lines) + + return nil +} diff --git a/pkg/logging/googlepubsub/doc.go b/pkg/commands/logging/googlepubsub/doc.go similarity index 100% rename from pkg/logging/googlepubsub/doc.go rename to pkg/commands/logging/googlepubsub/doc.go diff --git a/pkg/commands/logging/googlepubsub/googlepubsub_integration_test.go b/pkg/commands/logging/googlepubsub/googlepubsub_integration_test.go new file mode 100644 index 000000000..c6c8e4c9f --- /dev/null +++ b/pkg/commands/logging/googlepubsub/googlepubsub_integration_test.go @@ -0,0 +1,436 @@ +package googlepubsub_test + +import ( + "bytes" + "errors" + "io" + "strings" + "testing" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/app" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" +) + +func TestGooglePubSubCreate(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging googlepubsub create --service-id 123 --version 1 --name log --user user@example.com --secret-key secret --project-id project --topic topic --account-name=me@fastly.com --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + CreatePubsubFn: createGooglePubSubOK, + }, + wantOutput: "Created Google Cloud Pub/Sub logging endpoint log (service 123 version 4)", + }, + { + args: args("logging googlepubsub create --service-id 123 --version 1 --name log --user user@example.com --secret-key secret --project-id project --topic topic --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + CreatePubsubFn: createGooglePubSubError, + }, + wantError: errTest.Error(), + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertStringContains(t, stdout.String(), testcase.wantOutput) + }) + } +} + +func TestGooglePubSubList(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging googlepubsub list --service-id 123 --version 1"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListPubsubsFn: listGooglePubSubsOK, + }, + wantOutput: listGooglePubSubsShortOutput, + }, + { + args: args("logging googlepubsub list --service-id 123 --version 1 --verbose"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListPubsubsFn: listGooglePubSubsOK, + }, + wantOutput: listGooglePubSubsVerboseOutput, + }, + { + args: args("logging googlepubsub list --service-id 123 --version 1 -v"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListPubsubsFn: listGooglePubSubsOK, + }, + wantOutput: listGooglePubSubsVerboseOutput, + }, + { + args: args("logging googlepubsub --verbose list --service-id 123 --version 1"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListPubsubsFn: listGooglePubSubsOK, + }, + wantOutput: listGooglePubSubsVerboseOutput, + }, + { + args: args("logging -v googlepubsub list --service-id 123 --version 1"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListPubsubsFn: listGooglePubSubsOK, + }, + wantOutput: listGooglePubSubsVerboseOutput, + }, + { + args: args("logging googlepubsub list --service-id 123 --version 1"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListPubsubsFn: listGooglePubSubsError, + }, + wantError: errTest.Error(), + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertString(t, testcase.wantOutput, stdout.String()) + }) + } +} + +func TestGooglePubSubDescribe(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging googlepubsub describe --service-id 123 --version 1"), + wantError: "error parsing arguments: required flag --name not provided", + }, + { + args: args("logging googlepubsub describe --service-id 123 --version 1 --name logs"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + GetPubsubFn: getGooglePubSubError, + }, + wantError: errTest.Error(), + }, + { + args: args("logging googlepubsub describe --service-id 123 --version 1 --name logs"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + GetPubsubFn: getGooglePubSubOK, + }, + wantOutput: describeGooglePubSubOutput, + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertString(t, testcase.wantOutput, stdout.String()) + }) + } +} + +func TestGooglePubSubUpdate(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging googlepubsub update --service-id 123 --version 1 --new-name log"), + wantError: "error parsing arguments: required flag --name not provided", + }, + { + args: args("logging googlepubsub update --service-id 123 --version 1 --name logs --new-name log --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + UpdatePubsubFn: updateGooglePubSubError, + }, + wantError: errTest.Error(), + }, + { + args: args("logging googlepubsub update --service-id 123 --version 1 --name logs --new-name log --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + UpdatePubsubFn: updateGooglePubSubOK, + }, + wantOutput: "Updated Google Cloud Pub/Sub logging endpoint log (service 123 version 4)", + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertStringContains(t, stdout.String(), testcase.wantOutput) + }) + } +} + +func TestGooglePubSubDelete(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging googlepubsub delete --service-id 123 --version 1"), + wantError: "error parsing arguments: required flag --name not provided", + }, + { + args: args("logging googlepubsub delete --service-id 123 --version 1 --name logs --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + DeletePubsubFn: deleteGooglePubSubError, + }, + wantError: errTest.Error(), + }, + { + args: args("logging googlepubsub delete --service-id 123 --version 1 --name logs --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + DeletePubsubFn: deleteGooglePubSubOK, + }, + wantOutput: "Deleted Google Cloud Pub/Sub logging endpoint logs (service 123 version 4)", + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertStringContains(t, stdout.String(), testcase.wantOutput) + }) + } +} + +var errTest = errors.New("fixture error") + +func createGooglePubSubOK(i *fastly.CreatePubsubInput) (*fastly.Pubsub, error) { + return &fastly.Pubsub{ + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("log"), + Topic: fastly.ToPointer("topic"), + User: fastly.ToPointer("user"), + SecretKey: fastly.ToPointer("secret"), + ProjectID: fastly.ToPointer("project"), + AccountName: fastly.ToPointer("me@fastly.com"), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + FormatVersion: fastly.ToPointer(2), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + Placement: fastly.ToPointer("none"), + }, nil +} + +func createGooglePubSubError(_ *fastly.CreatePubsubInput) (*fastly.Pubsub, error) { + return nil, errTest +} + +func listGooglePubSubsOK(i *fastly.ListPubsubsInput) ([]*fastly.Pubsub, error) { + return []*fastly.Pubsub{ + { + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("logs"), + User: fastly.ToPointer("user@example.com"), + AccountName: fastly.ToPointer("none"), + SecretKey: fastly.ToPointer("secret"), + ProjectID: fastly.ToPointer("project"), + Topic: fastly.ToPointer("topic"), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + Placement: fastly.ToPointer("none"), + FormatVersion: fastly.ToPointer(2), + }, + { + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("analytics"), + User: fastly.ToPointer("user@example.com"), + AccountName: fastly.ToPointer("none"), + SecretKey: fastly.ToPointer("secret"), + ProjectID: fastly.ToPointer("project"), + Topic: fastly.ToPointer("analytics"), + Placement: fastly.ToPointer("none"), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + FormatVersion: fastly.ToPointer(2), + }, + }, nil +} + +func listGooglePubSubsError(_ *fastly.ListPubsubsInput) ([]*fastly.Pubsub, error) { + return nil, errTest +} + +var listGooglePubSubsShortOutput = strings.TrimSpace(` +SERVICE VERSION NAME +123 1 logs +123 1 analytics +`) + "\n" + +var listGooglePubSubsVerboseOutput = strings.TrimSpace(` +Fastly API endpoint: https://api.fastly.com +Fastly API token provided via config file (profile: user) + +Service ID (via --service-id): 123 + +Version: 1 + Google Cloud Pub/Sub 1/2 + Service ID: 123 + Version: 1 + Name: logs + User: user@example.com + Account name: none + Secret key: secret + Project ID: project + Topic: topic + Format: %h %l %u %t "%r" %>s %b + Format version: 2 + Response condition: Prevent default logging + Placement: none + Google Cloud Pub/Sub 2/2 + Service ID: 123 + Version: 1 + Name: analytics + User: user@example.com + Account name: none + Secret key: secret + Project ID: project + Topic: analytics + Format: %h %l %u %t "%r" %>s %b + Format version: 2 + Response condition: Prevent default logging + Placement: none +`) + "\n\n" + +func getGooglePubSubOK(i *fastly.GetPubsubInput) (*fastly.Pubsub, error) { + return &fastly.Pubsub{ + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("logs"), + Topic: fastly.ToPointer("topic"), + User: fastly.ToPointer("user@example.com"), + AccountName: fastly.ToPointer("none"), + SecretKey: fastly.ToPointer("secret"), + ProjectID: fastly.ToPointer("project"), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + FormatVersion: fastly.ToPointer(2), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + Placement: fastly.ToPointer("none"), + }, nil +} + +func getGooglePubSubError(_ *fastly.GetPubsubInput) (*fastly.Pubsub, error) { + return nil, errTest +} + +var describeGooglePubSubOutput = "\n" + strings.TrimSpace(` +Account name: none +Format: %h %l %u %t "%r" %>s %b +Format version: 2 +Name: logs +Placement: none +Project ID: project +Response condition: Prevent default logging +Secret key: secret +Service ID: 123 +Topic: topic +User: user@example.com +Version: 1 +`) + "\n" + +func updateGooglePubSubOK(i *fastly.UpdatePubsubInput) (*fastly.Pubsub, error) { + return &fastly.Pubsub{ + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("log"), + Topic: fastly.ToPointer("topic"), + User: fastly.ToPointer("user@example.com"), + SecretKey: fastly.ToPointer("secret"), + ProjectID: fastly.ToPointer("project"), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + FormatVersion: fastly.ToPointer(2), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + Placement: fastly.ToPointer("none"), + }, nil +} + +func updateGooglePubSubError(_ *fastly.UpdatePubsubInput) (*fastly.Pubsub, error) { + return nil, errTest +} + +func deleteGooglePubSubOK(_ *fastly.DeletePubsubInput) error { + return nil +} + +func deleteGooglePubSubError(_ *fastly.DeletePubsubInput) error { + return errTest +} diff --git a/pkg/commands/logging/googlepubsub/googlepubsub_test.go b/pkg/commands/logging/googlepubsub/googlepubsub_test.go new file mode 100644 index 000000000..355fcd901 --- /dev/null +++ b/pkg/commands/logging/googlepubsub/googlepubsub_test.go @@ -0,0 +1,354 @@ +package googlepubsub_test + +import ( + "bytes" + "testing" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/logging/googlepubsub" + "github.com/fastly/cli/pkg/config" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" +) + +func TestCreateGooglePubSubInput(t *testing.T) { + for _, testcase := range []struct { + name string + cmd *googlepubsub.CreateCommand + want *fastly.CreatePubsubInput + wantError string + }{ + { + name: "required values set flag serviceID", + cmd: createCommandRequired(), + want: &fastly.CreatePubsubInput{ + ServiceID: "123", + ServiceVersion: 4, + Name: fastly.ToPointer("log"), + User: fastly.ToPointer("user@example.com"), + SecretKey: fastly.ToPointer("secret"), + ProjectID: fastly.ToPointer("project"), + Topic: fastly.ToPointer("topic"), + }, + }, + { + name: "all values set flag serviceID", + cmd: createCommandAll(), + want: &fastly.CreatePubsubInput{ + ServiceID: "123", + ServiceVersion: 4, + Name: fastly.ToPointer("logs"), + Topic: fastly.ToPointer("topic"), + User: fastly.ToPointer("user@example.com"), + SecretKey: fastly.ToPointer("secret"), + ProjectID: fastly.ToPointer("project"), + FormatVersion: fastly.ToPointer(2), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + Placement: fastly.ToPointer("none"), + }, + }, + { + name: "error missing serviceID", + cmd: createCommandMissingServiceID(), + want: nil, + wantError: errors.ErrNoServiceID.Error(), + }, + } { + t.Run(testcase.name, func(t *testing.T) { + var bs []byte + out := bytes.NewBuffer(bs) + verboseMode := true + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + AutoCloneFlag: testcase.cmd.AutoClone, + APIClient: testcase.cmd.Globals.APIClient, + Manifest: testcase.cmd.Manifest, + Out: out, + ServiceVersionFlag: testcase.cmd.ServiceVersion, + VerboseMode: verboseMode, + }) + + switch { + case err != nil && testcase.wantError == "": + t.Fatalf("unexpected error getting service details: %v", err) + return + case err != nil && testcase.wantError != "": + testutil.AssertErrorContains(t, err, testcase.wantError) + return + case err == nil && testcase.wantError != "": + t.Fatalf("expected error, have nil (service details: %s, %d)", serviceID, serviceVersion.Number) + case err == nil && testcase.wantError == "": + have, err := testcase.cmd.ConstructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertEqual(t, testcase.want, have) + } + }) + } +} + +func TestUpdateGooglePubSubInput(t *testing.T) { + scenarios := []struct { + name string + cmd *googlepubsub.UpdateCommand + api mock.API + want *fastly.UpdatePubsubInput + wantError string + }{ + { + name: "all values set flag serviceID", + cmd: updateCommandAll(), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + GetPubsubFn: getGooglePubSubOK, + }, + want: &fastly.UpdatePubsubInput{ + ServiceID: "123", + ServiceVersion: 4, + Name: "log", + NewName: fastly.ToPointer("new1"), + User: fastly.ToPointer("new2"), + SecretKey: fastly.ToPointer("new3"), + ProjectID: fastly.ToPointer("new4"), + Topic: fastly.ToPointer("new5"), + Placement: fastly.ToPointer("new6"), + Format: fastly.ToPointer("new7"), + FormatVersion: fastly.ToPointer(3), + ResponseCondition: fastly.ToPointer("new8"), + }, + }, + { + name: "no updates", + cmd: updateCommandNoUpdates(), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + GetPubsubFn: getGooglePubSubOK, + }, + want: &fastly.UpdatePubsubInput{ + ServiceID: "123", + ServiceVersion: 4, + Name: "log", + }, + }, + { + name: "error missing serviceID", + cmd: updateCommandMissingServiceID(), + want: nil, + wantError: errors.ErrNoServiceID.Error(), + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(testcase.name, func(t *testing.T) { + testcase.cmd.Globals.APIClient = testcase.api + + var bs []byte + out := bytes.NewBuffer(bs) + verboseMode := true + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + AutoCloneFlag: testcase.cmd.AutoClone, + APIClient: testcase.api, + Manifest: testcase.cmd.Manifest, + Out: out, + ServiceVersionFlag: testcase.cmd.ServiceVersion, + VerboseMode: verboseMode, + }) + + switch { + case err != nil && testcase.wantError == "": + t.Fatalf("unexpected error getting service details: %v", err) + return + case err != nil && testcase.wantError != "": + testutil.AssertErrorContains(t, err, testcase.wantError) + return + case err == nil && testcase.wantError != "": + t.Fatalf("expected error, have nil (service details: %s, %d)", serviceID, serviceVersion.Number) + case err == nil && testcase.wantError == "": + have, err := testcase.cmd.ConstructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertEqual(t, testcase.want, have) + } + }) + } +} + +func createCommandRequired() *googlepubsub.CreateCommand { + var b bytes.Buffer + + g := global.Data{ + Config: config.File{}, + Env: config.Environment{}, + Output: &b, + } + g.APIClient, _ = mock.APIClient(mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + })("token", "endpoint", false) + + return &googlepubsub.CreateCommand{ + Base: argparser.Base{ + Globals: &g, + }, + Manifest: manifest.Data{ + Flag: manifest.Flag{ + ServiceID: "123", + }, + }, + ServiceVersion: argparser.OptionalServiceVersion{ + OptionalString: argparser.OptionalString{Value: "1"}, + }, + AutoClone: argparser.OptionalAutoClone{ + OptionalBool: argparser.OptionalBool{ + Optional: argparser.Optional{ + WasSet: true, + }, + Value: true, + }, + }, + EndpointName: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "log"}, + User: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "user@example.com"}, + SecretKey: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "secret"}, + ProjectID: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "project"}, + Topic: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "topic"}, + } +} + +func createCommandAll() *googlepubsub.CreateCommand { + var b bytes.Buffer + + g := global.Data{ + Config: config.File{}, + Env: config.Environment{}, + Output: &b, + } + g.APIClient, _ = mock.APIClient(mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + })("token", "endpoint", false) + + return &googlepubsub.CreateCommand{ + Base: argparser.Base{ + Globals: &g, + }, + Manifest: manifest.Data{ + Flag: manifest.Flag{ + ServiceID: "123", + }, + }, + ServiceVersion: argparser.OptionalServiceVersion{ + OptionalString: argparser.OptionalString{Value: "1"}, + }, + AutoClone: argparser.OptionalAutoClone{ + OptionalBool: argparser.OptionalBool{ + Optional: argparser.Optional{ + WasSet: true, + }, + Value: true, + }, + }, + EndpointName: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "logs"}, + User: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "user@example.com"}, + ProjectID: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "project"}, + Topic: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "topic"}, + SecretKey: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "secret"}, + Format: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: `%h %l %u %t "%r" %>s %b`}, + FormatVersion: argparser.OptionalInt{Optional: argparser.Optional{WasSet: true}, Value: 2}, + ResponseCondition: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "Prevent default logging"}, + Placement: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "none"}, + } +} + +func createCommandMissingServiceID() *googlepubsub.CreateCommand { + res := createCommandAll() + res.Manifest = manifest.Data{} + return res +} + +func updateCommandNoUpdates() *googlepubsub.UpdateCommand { + var b bytes.Buffer + + g := global.Data{ + Config: config.File{}, + Env: config.Environment{}, + Output: &b, + } + + return &googlepubsub.UpdateCommand{ + Base: argparser.Base{ + Globals: &g, + }, + Manifest: manifest.Data{ + Flag: manifest.Flag{ + ServiceID: "123", + }, + }, + EndpointName: "log", + ServiceVersion: argparser.OptionalServiceVersion{ + OptionalString: argparser.OptionalString{Value: "1"}, + }, + AutoClone: argparser.OptionalAutoClone{ + OptionalBool: argparser.OptionalBool{ + Optional: argparser.Optional{ + WasSet: true, + }, + Value: true, + }, + }, + } +} + +func updateCommandAll() *googlepubsub.UpdateCommand { + var b bytes.Buffer + + g := global.Data{ + Config: config.File{}, + Env: config.Environment{}, + Output: &b, + } + + return &googlepubsub.UpdateCommand{ + Base: argparser.Base{ + Globals: &g, + }, + Manifest: manifest.Data{ + Flag: manifest.Flag{ + ServiceID: "123", + }, + }, + EndpointName: "log", + ServiceVersion: argparser.OptionalServiceVersion{ + OptionalString: argparser.OptionalString{Value: "1"}, + }, + AutoClone: argparser.OptionalAutoClone{ + OptionalBool: argparser.OptionalBool{ + Optional: argparser.Optional{ + WasSet: true, + }, + Value: true, + }, + }, + NewName: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new1"}, + User: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new2"}, + SecretKey: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new3"}, + ProjectID: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new4"}, + Topic: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new5"}, + Placement: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new6"}, + Format: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new7"}, + FormatVersion: argparser.OptionalInt{Optional: argparser.Optional{WasSet: true}, Value: 3}, + ResponseCondition: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new8"}, + } +} + +func updateCommandMissingServiceID() *googlepubsub.UpdateCommand { + res := updateCommandAll() + res.Manifest = manifest.Data{} + return res +} diff --git a/pkg/commands/logging/googlepubsub/list.go b/pkg/commands/logging/googlepubsub/list.go new file mode 100644 index 000000000..1a119d2e4 --- /dev/null +++ b/pkg/commands/logging/googlepubsub/list.go @@ -0,0 +1,127 @@ +package googlepubsub + +import ( + "fmt" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// ListCommand calls the Fastly API to list Google Cloud Pub/Sub logging endpoints. +type ListCommand struct { + argparser.Base + argparser.JSONOutput + + Input fastly.ListPubsubsInput + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion +} + +// NewListCommand returns a usable command registered under the parent. +func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { + c := ListCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("list", "List Google Cloud Pub/Sub endpoints on a Fastly service version") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + return &c +} + +// Exec invokes the application logic for the command. +func (c *ListCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fsterr.ServiceVersion(serviceVersion), + }) + return err + } + + c.Input.ServiceID = serviceID + c.Input.ServiceVersion = fastly.ToValue(serviceVersion.Number) + + o, err := c.Globals.APIClient.ListPubsubs(&c.Input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + if !c.Globals.Verbose() { + tw := text.NewTable(out) + tw.AddHeader("SERVICE", "VERSION", "NAME") + for _, googlepubsub := range o { + tw.AddLine( + fastly.ToValue(googlepubsub.ServiceID), + fastly.ToValue(googlepubsub.ServiceVersion), + fastly.ToValue(googlepubsub.Name), + ) + } + tw.Print() + return nil + } + + fmt.Fprintf(out, "Version: %d\n", c.Input.ServiceVersion) + for i, googlepubsub := range o { + fmt.Fprintf(out, "\tGoogle Cloud Pub/Sub %d/%d\n", i+1, len(o)) + fmt.Fprintf(out, "\t\tService ID: %s\n", fastly.ToValue(googlepubsub.ServiceID)) + fmt.Fprintf(out, "\t\tVersion: %d\n", fastly.ToValue(googlepubsub.ServiceVersion)) + fmt.Fprintf(out, "\t\tName: %s\n", fastly.ToValue(googlepubsub.Name)) + fmt.Fprintf(out, "\t\tUser: %s\n", fastly.ToValue(googlepubsub.User)) + fmt.Fprintf(out, "\t\tAccount name: %s\n", fastly.ToValue(googlepubsub.AccountName)) + fmt.Fprintf(out, "\t\tSecret key: %s\n", fastly.ToValue(googlepubsub.SecretKey)) + fmt.Fprintf(out, "\t\tProject ID: %s\n", fastly.ToValue(googlepubsub.ProjectID)) + fmt.Fprintf(out, "\t\tTopic: %s\n", fastly.ToValue(googlepubsub.Topic)) + fmt.Fprintf(out, "\t\tFormat: %s\n", fastly.ToValue(googlepubsub.Format)) + fmt.Fprintf(out, "\t\tFormat version: %d\n", fastly.ToValue(googlepubsub.FormatVersion)) + fmt.Fprintf(out, "\t\tResponse condition: %s\n", fastly.ToValue(googlepubsub.ResponseCondition)) + fmt.Fprintf(out, "\t\tPlacement: %s\n", fastly.ToValue(googlepubsub.Placement)) + } + fmt.Fprintln(out) + + return nil +} diff --git a/pkg/commands/logging/googlepubsub/root.go b/pkg/commands/logging/googlepubsub/root.go new file mode 100644 index 000000000..ebbfb245b --- /dev/null +++ b/pkg/commands/logging/googlepubsub/root.go @@ -0,0 +1,31 @@ +package googlepubsub + +import ( + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" +) + +// RootCommand is the parent command for all subcommands in this package. +// It should be installed under the primary root command. +type RootCommand struct { + argparser.Base + // no flags +} + +// CommandName is the string to be used to invoke this command. +const CommandName = "googlepubsub" + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent argparser.Registerer, g *global.Data) *RootCommand { + var c RootCommand + c.Globals = g + c.CmdClause = parent.Command(CommandName, "Manipulate Fastly service version Google Cloud Pub/Sub logging endpoints") + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { + panic("unreachable") +} diff --git a/pkg/commands/logging/googlepubsub/update.go b/pkg/commands/logging/googlepubsub/update.go new file mode 100644 index 000000000..a8571c74f --- /dev/null +++ b/pkg/commands/logging/googlepubsub/update.go @@ -0,0 +1,172 @@ +package googlepubsub + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/logging/common" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/text" +) + +// UpdateCommand calls the Fastly API to update a Google Cloud Pub/Sub logging endpoint. +type UpdateCommand struct { + argparser.Base + Manifest manifest.Data + + // Required. + EndpointName string // Can't shadow argparser.Base method Name(). + ServiceName argparser.OptionalServiceNameID + ServiceVersion argparser.OptionalServiceVersion + + // Optional. + AccountName argparser.OptionalString + AutoClone argparser.OptionalAutoClone + Format argparser.OptionalString + FormatVersion argparser.OptionalInt + NewName argparser.OptionalString + Placement argparser.OptionalString + ProjectID argparser.OptionalString + ResponseCondition argparser.OptionalString + SecretKey argparser.OptionalString + Topic argparser.OptionalString + User argparser.OptionalString +} + +// NewUpdateCommand returns a usable command registered under the parent. +func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { + c := UpdateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("update", "Update a Google Cloud Pub/Sub logging endpoint on a Fastly service version") + + // Required. + c.CmdClause.Flag("name", "The name of the Google Cloud Pub/Sub logging object").Short('n').Required().StringVar(&c.EndpointName) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.ServiceVersion.Value, + Required: true, + }) + + // Optional. + common.AccountName(c.CmdClause, &c.AccountName) + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.AutoClone.Set, + Dst: &c.AutoClone.Value, + }) + common.Format(c.CmdClause, &c.Format) + common.FormatVersion(c.CmdClause, &c.FormatVersion) + c.CmdClause.Flag("new-name", "New name of the Google Cloud Pub/Sub logging object").Action(c.NewName.Set).StringVar(&c.NewName.Value) + common.Placement(c.CmdClause, &c.Placement) + c.CmdClause.Flag("project-id", "The ID of your Google Cloud Platform project").Action(c.ProjectID.Set).StringVar(&c.ProjectID.Value) + c.CmdClause.Flag("secret-key", "Your Google Cloud Platform account secret key. The private_key field in your service account authentication JSON").Action(c.SecretKey.Set).StringVar(&c.SecretKey.Value) + common.ResponseCondition(c.CmdClause, &c.ResponseCondition) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.ServiceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.ServiceName.Value, + }) + c.CmdClause.Flag("topic", "The Google Cloud Pub/Sub topic to which logs will be published").Action(c.Topic.Set).StringVar(&c.Topic.Value) + c.CmdClause.Flag("user", "Your Google Cloud Platform service account email address. The client_email field in your service account authentication JSON").Action(c.User.Set).StringVar(&c.User.Value) + return &c +} + +// ConstructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *UpdateCommand) ConstructInput(serviceID string, serviceVersion int) (*fastly.UpdatePubsubInput, error) { + input := fastly.UpdatePubsubInput{ + ServiceID: serviceID, + ServiceVersion: serviceVersion, + Name: c.EndpointName, + } + + if c.AccountName.WasSet { + input.AccountName = &c.AccountName.Value + } + if c.Format.WasSet { + input.Format = fastly.ToPointer(argparser.Content(c.Format.Value)) + } + if c.FormatVersion.WasSet { + input.FormatVersion = &c.FormatVersion.Value + } + if c.NewName.WasSet { + input.NewName = &c.NewName.Value + } + if c.Placement.WasSet { + input.Placement = &c.Placement.Value + } + if c.ProjectID.WasSet { + input.ProjectID = &c.ProjectID.Value + } + if c.ResponseCondition.WasSet { + input.ResponseCondition = &c.ResponseCondition.Value + } + if c.SecretKey.WasSet { + input.SecretKey = &c.SecretKey.Value + } + if c.Topic.WasSet { + input.Topic = &c.Topic.Value + } + if c.User.WasSet { + input.User = &c.User.Value + } + + return &input, nil +} + +// Exec invokes the application logic for the command. +func (c *UpdateCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.AutoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.ServiceName, + ServiceVersionFlag: c.ServiceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + input, err := c.ConstructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + googlepubsub, err := c.Globals.APIClient.UpdatePubsub(input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + text.Success(out, + "Updated Google Cloud Pub/Sub logging endpoint %s (service %s version %d)", + fastly.ToValue(googlepubsub.Name), + fastly.ToValue(googlepubsub.ServiceID), + fastly.ToValue(googlepubsub.ServiceVersion), + ) + return nil +} diff --git a/pkg/commands/logging/grafanacloudlogs/create.go b/pkg/commands/logging/grafanacloudlogs/create.go new file mode 100644 index 000000000..87e59a8de --- /dev/null +++ b/pkg/commands/logging/grafanacloudlogs/create.go @@ -0,0 +1,170 @@ +package grafanacloudlogs + +import ( + "io" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/logging/common" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/text" + "github.com/fastly/go-fastly/v10/fastly" +) + +// CreateCommand calls the Fastly API to create a GrafanaCloudLogs logging endpoint. +type CreateCommand struct { + argparser.Base + Manifest manifest.Data + + // Required. + ServiceName argparser.OptionalServiceNameID + ServiceVersion argparser.OptionalServiceVersion + User argparser.OptionalString + URL argparser.OptionalString + Index argparser.OptionalString + Token argparser.OptionalString + + // Optional. + AutoClone argparser.OptionalAutoClone + EndpointName argparser.OptionalString // Can't shadow argparser.Base method Name(). + Format argparser.OptionalString + FormatVersion argparser.OptionalInt + MessageType argparser.OptionalString + Placement argparser.OptionalString + ResponseCondition argparser.OptionalString + TimestampFormat argparser.OptionalString +} + +// NewCreateCommand returns a usable command registered under the parent. +func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { + c := CreateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("create", "Create a Grafana Cloud Logs logging endpoint on a Fastly service version").Alias("add") + + // Required. + c.CmdClause.Flag("name", "The name of the Grafana Cloud Logs logging endpoint. Used as a primary key for API access").Short('n').Action(c.EndpointName.Set).StringVar(&c.EndpointName.Value) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.ServiceVersion.Value, + Required: true, + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.ServiceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.ServiceName.Value, + }) + + // Optional. + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.AutoClone.Set, + Dst: &c.AutoClone.Value, + }) + common.Format(c.CmdClause, &c.Format) + common.FormatVersion(c.CmdClause, &c.FormatVersion) + common.MessageType(c.CmdClause, &c.MessageType) + common.Placement(c.CmdClause, &c.Placement) + common.ResponseCondition(c.CmdClause, &c.ResponseCondition) + + c.CmdClause.Flag("index", `The stream identifier`).Action(c.Index.Set).StringVar(&c.Index.Value) + c.CmdClause.Flag("url", "The URL of your Grafana instance").Action(c.URL.Set).StringVar(&c.URL.Value) + c.CmdClause.Flag("user", "Your Grafana User ID.").Action(c.User.Set).StringVar(&c.User.Value) + c.CmdClause.Flag("auth-token", "Your Grafana Access Policy Token").Action(c.Token.Set).StringVar(&c.Token.Value) + + return &c +} + +// ConstructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *CreateCommand) ConstructInput(serviceID string, serviceVersion int) (*fastly.CreateGrafanaCloudLogsInput, error) { + input := fastly.CreateGrafanaCloudLogsInput{ + ServiceID: serviceID, + ServiceVersion: serviceVersion, + } + + if c.EndpointName.WasSet { + input.Name = &c.EndpointName.Value + } + if c.Format.WasSet { + input.Format = fastly.ToPointer(argparser.Content(c.Format.Value)) + } + if c.FormatVersion.WasSet { + input.FormatVersion = &c.FormatVersion.Value + } + if c.MessageType.WasSet { + input.MessageType = &c.MessageType.Value + } + if c.Index.WasSet { + input.Index = &c.Index.Value + } + if c.URL.WasSet { + input.URL = &c.URL.Value + } + if c.User.WasSet { + input.User = &c.User.Value + } + if c.Token.WasSet { + input.Token = &c.Token.Value + } + if c.Placement.WasSet { + input.Placement = &c.Placement.Value + } + if c.ResponseCondition.WasSet { + input.ResponseCondition = &c.ResponseCondition.Value + } + return &input, nil +} + +// Exec invokes the application logic for the command. +func (c *CreateCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.AutoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.ServiceName, + ServiceVersionFlag: c.ServiceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + input, err := c.ConstructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + d, err := c.Globals.APIClient.CreateGrafanaCloudLogs(input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + text.Success(out, + "Created Grafana Cloud Logs logging endpoint %s (service %s version %d)", + fastly.ToValue(d.Name), + fastly.ToValue(d.ServiceID), + fastly.ToValue(d.ServiceVersion), + ) + return nil +} diff --git a/pkg/commands/logging/grafanacloudlogs/delete.go b/pkg/commands/logging/grafanacloudlogs/delete.go new file mode 100644 index 000000000..7ba63c9ae --- /dev/null +++ b/pkg/commands/logging/grafanacloudlogs/delete.go @@ -0,0 +1,94 @@ +package grafanacloudlogs + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// DeleteCommand calls the Fastly API to delete a Grafana Cloud Logs logging endpoint. +type DeleteCommand struct { + argparser.Base + Input fastly.DeleteGrafanaCloudLogsInput + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion + autoClone argparser.OptionalAutoClone +} + +// NewDeleteCommand returns a usable command registered under the parent. +func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { + c := DeleteCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("delete", "Delete a GrafanaCloudLogs logging endpoint on a Fastly service version").Alias("remove") + + // Required. + c.CmdClause.Flag("name", "The name of the Grafana Cloud Logs logging object").Short('n').Required().StringVar(&c.Input.Name) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.autoClone.Set, + Dst: &c.autoClone.Value, + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + return &c +} + +// Exec invokes the application logic for the command. +func (c *DeleteCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.autoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + c.Input.ServiceID = serviceID + c.Input.ServiceVersion = fastly.ToValue(serviceVersion.Number) + + if err := c.Globals.APIClient.DeleteGrafanaCloudLogs(&c.Input); err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + text.Success(out, "Deleted Grafana Cloud Logs logging endpoint %s (service %s version %d)", c.Input.Name, c.Input.ServiceID, c.Input.ServiceVersion) + return nil +} diff --git a/pkg/commands/logging/grafanacloudlogs/describe.go b/pkg/commands/logging/grafanacloudlogs/describe.go new file mode 100644 index 000000000..d2e6e3a48 --- /dev/null +++ b/pkg/commands/logging/grafanacloudlogs/describe.go @@ -0,0 +1,113 @@ +package grafanacloudlogs + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// DescribeCommand calls the Fastly API to describe a Grafana Cloud Logs logging endpoint. +type DescribeCommand struct { + argparser.Base + argparser.JSONOutput + + Input fastly.GetGrafanaCloudLogsInput + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion +} + +// NewDescribeCommand returns a usable command registered under the parent. +func NewDescribeCommand(parent argparser.Registerer, g *global.Data) *DescribeCommand { + c := DescribeCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("describe", "Show detailed information about a Grafana Cloud Logs logging endpoint on a Fastly service version").Alias("get") + + // Required. + c.CmdClause.Flag("name", "The name of the Grafana Cloud Logs logging object").Short('n').Required().StringVar(&c.Input.Name) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + return &c +} + +// Exec invokes the application logic for the command. +func (c *DescribeCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fsterr.ServiceVersion(serviceVersion), + }) + return err + } + + c.Input.ServiceID = serviceID + c.Input.ServiceVersion = fastly.ToValue(serviceVersion.Number) + + o, err := c.Globals.APIClient.GetGrafanaCloudLogs(&c.Input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + lines := text.Lines{ + "Format version": fastly.ToValue(o.FormatVersion), + "Format": fastly.ToValue(o.Format), + "Message type": fastly.ToValue(o.MessageType), + "Name": fastly.ToValue(o.Name), + "Placement": fastly.ToValue(o.Placement), + "Response condition": fastly.ToValue(o.ResponseCondition), + "Version": fastly.ToValue(o.ServiceVersion), + "User": fastly.ToValue(o.User), + "URL": fastly.ToValue(o.URL), + "Token": fastly.ToValue(o.Token), + "Index": fastly.ToValue(o.Index), + } + if !c.Globals.Verbose() { + lines["Service ID"] = fastly.ToValue(o.ServiceID) + } + text.PrintLines(out, lines) + + return nil +} diff --git a/pkg/commands/logging/grafanacloudlogs/doc.go b/pkg/commands/logging/grafanacloudlogs/doc.go new file mode 100644 index 000000000..ba6e8537d --- /dev/null +++ b/pkg/commands/logging/grafanacloudlogs/doc.go @@ -0,0 +1,3 @@ +// Package grafanacloudlogs contains commands to inspect and manipulate Fastly service Grafana Cloud Logs +// logging endpoints. +package grafanacloudlogs diff --git a/pkg/commands/logging/grafanacloudlogs/grafanacloud_logs_integration_test.go b/pkg/commands/logging/grafanacloudlogs/grafanacloud_logs_integration_test.go new file mode 100644 index 000000000..c0fe48452 --- /dev/null +++ b/pkg/commands/logging/grafanacloudlogs/grafanacloud_logs_integration_test.go @@ -0,0 +1,427 @@ +package grafanacloudlogs_test + +import ( + "bytes" + "errors" + "io" + "strings" + "testing" + + "github.com/fastly/cli/pkg/app" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" + "github.com/fastly/go-fastly/v10/fastly" +) + +func TestGrafanaCloudLogsCreate(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging grafanacloudlogs create --service-id 123 --version 1 --name log --user 123456 --url https://test123.grafana.net --auth-token testtoken --index `{\"label\": \"value\" }` --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + CreateGrafanaCloudLogsFn: createGrafanaCloudLogsOK, + }, + wantOutput: "Created Grafana Cloud Logs logging endpoint log (service 123 version 4)", + }, + { + args: args("logging grafanacloudlogs create --service-id 123 --version 1 --name log --url https://test123.grafana.net --auth-token testtoken --index `{\"label\": \"value\" }` --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + CreateGrafanaCloudLogsFn: createGrafanaCloudLogsError, + }, + wantError: errTest.Error(), + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertStringContains(t, stdout.String(), testcase.wantOutput) + }) + } +} + +func TestGrafanaCloudLogsList(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging grafanacloudlogs list --service-id 123 --version 1"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListGrafanaCloudLogsFn: listGrafanaCloudLogsOK, + }, + wantOutput: listGrafanaCloudLogsShortOutput, + }, + { + args: args("logging grafanacloudlogs list --service-id 123 --version 1 --verbose"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListGrafanaCloudLogsFn: listGrafanaCloudLogsOK, + }, + wantOutput: listGrafanaCloudLogsVerboseOutput, + }, + { + args: args("logging grafanacloudlogs list --service-id 123 --version 1 -v"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListGrafanaCloudLogsFn: listGrafanaCloudLogsOK, + }, + wantOutput: listGrafanaCloudLogsVerboseOutput, + }, + { + args: args("logging grafanacloudlogs --verbose list --service-id 123 --version 1"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListGrafanaCloudLogsFn: listGrafanaCloudLogsOK, + }, + wantOutput: listGrafanaCloudLogsVerboseOutput, + }, + { + args: args("logging -v grafanacloudlogs list --service-id 123 --version 1"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListGrafanaCloudLogsFn: listGrafanaCloudLogsOK, + }, + wantOutput: listGrafanaCloudLogsVerboseOutput, + }, + { + args: args("logging grafanacloudlogs list --service-id 123 --version 1"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListGrafanaCloudLogsFn: listGrafanaCloudLogsError, + }, + wantError: errTest.Error(), + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertString(t, testcase.wantOutput, stdout.String()) + }) + } +} + +func TestGrafanaCloudLogsDescribe(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging grafanacloudlogs describe --service-id 123 --version 1"), + wantError: "error parsing arguments: required flag --name not provided", + }, + { + args: args("logging grafanacloudlogs describe --service-id 123 --version 1 --name logs"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + GetGrafanaCloudLogsFn: getGrafanaCloudLogsError, + }, + wantError: errTest.Error(), + }, + { + args: args("logging grafanacloudlogs describe --service-id 123 --version 1 --name logs"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + GetGrafanaCloudLogsFn: getGrafanaCloudLogsOK, + }, + wantOutput: describeGrafanaCloudLogsOutput, + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertString(t, testcase.wantOutput, stdout.String()) + }) + } +} + +func TestGrafanaCloudLogsUpdate(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging grafanacloudlogs update --service-id 123 --version 1 --new-name log"), + wantError: "error parsing arguments: required flag --name not provided", + }, + { + args: args("logging grafanacloudlogs update --service-id 123 --version 1 --name logs --new-name log --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + UpdateGrafanaCloudLogsFn: updateGrafanaCloudLogsError, + }, + wantError: errTest.Error(), + }, + { + args: args("logging grafanacloudlogs update --service-id 123 --version 1 --name logs --new-name log --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + UpdateGrafanaCloudLogsFn: updateGrafanaCloudLogsOK, + }, + wantOutput: "Updated Grafana Cloud Logs logging endpoint log (service 123 version 4)", + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertStringContains(t, stdout.String(), testcase.wantOutput) + }) + } +} + +func TestGrafanaCloudLogsDelete(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging grafanacloudlogs delete --service-id 123 --version 1"), + wantError: "error parsing arguments: required flag --name not provided", + }, + { + args: args("logging grafanacloudlogs delete --service-id 123 --version 1 --name logs --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + DeleteGrafanaCloudLogsFn: deleteGrafanaCloudLogsError, + }, + wantError: errTest.Error(), + }, + { + args: args("logging grafanacloudlogs delete --service-id 123 --version 1 --name logs --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + DeleteGrafanaCloudLogsFn: deleteGrafanaCloudLogsOK, + }, + wantOutput: "Deleted Grafana Cloud Logs logging endpoint logs (service 123 version 4)", + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertStringContains(t, stdout.String(), testcase.wantOutput) + }) + } +} + +var errTest = errors.New("fixture error") + +func createGrafanaCloudLogsOK(i *fastly.CreateGrafanaCloudLogsInput) (*fastly.GrafanaCloudLogs, error) { + return &fastly.GrafanaCloudLogs{ + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: i.Name, + }, nil +} + +func createGrafanaCloudLogsError(_ *fastly.CreateGrafanaCloudLogsInput) (*fastly.GrafanaCloudLogs, error) { + return nil, errTest +} + +func listGrafanaCloudLogsOK(i *fastly.ListGrafanaCloudLogsInput) ([]*fastly.GrafanaCloudLogs, error) { + return []*fastly.GrafanaCloudLogs{ + { + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("logs"), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + FormatVersion: fastly.ToPointer(2), + MessageType: fastly.ToPointer("classic"), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + Placement: fastly.ToPointer("none"), + User: fastly.ToPointer("123456"), + Token: fastly.ToPointer("testtoken"), + URL: fastly.ToPointer("https://test123.grafana.net"), + Index: fastly.ToPointer("{\"label\": \"value\"}"), + }, + { + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("analytics"), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + FormatVersion: fastly.ToPointer(2), + MessageType: fastly.ToPointer("classic"), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + Placement: fastly.ToPointer("none"), + User: fastly.ToPointer("123456"), + Token: fastly.ToPointer("testtoken"), + URL: fastly.ToPointer("https://test123.grafana.net"), + Index: fastly.ToPointer("{\"label\": \"value\"}"), + }, + }, nil +} + +func listGrafanaCloudLogsError(_ *fastly.ListGrafanaCloudLogsInput) ([]*fastly.GrafanaCloudLogs, error) { + return nil, errTest +} + +var listGrafanaCloudLogsShortOutput = strings.TrimSpace(` +SERVICE VERSION NAME +123 1 logs +123 1 analytics +`) + "\n" + +var listGrafanaCloudLogsVerboseOutput = strings.TrimSpace(` +Fastly API endpoint: https://api.fastly.com +Fastly API token provided via config file (profile: user) + +Service ID (via --service-id): 123 + +Version: 1 + GrafanaCloudLogs 1/2 + Service ID: 123 + Version: 1 + Name: logs + Format: %h %l %u %t "%r" %>s %b + Format version: 2 + Response condition: Prevent default logging + Message type: classic + Placement: none + User: 123456 + URL: https://test123.grafana.net + Token: testtoken + Index: {"label": "value"} + GrafanaCloudLogs 2/2 + Service ID: 123 + Version: 1 + Name: analytics + Format: %h %l %u %t "%r" %>s %b + Format version: 2 + Response condition: Prevent default logging + Message type: classic + Placement: none + User: 123456 + URL: https://test123.grafana.net + Token: testtoken + Index: {"label": "value"} +`) + "\n\n" + +func getGrafanaCloudLogsOK(i *fastly.GetGrafanaCloudLogsInput) (*fastly.GrafanaCloudLogs, error) { + return &fastly.GrafanaCloudLogs{ + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("logs"), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + FormatVersion: fastly.ToPointer(2), + MessageType: fastly.ToPointer("classic"), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + Placement: fastly.ToPointer("none"), + User: fastly.ToPointer("123456"), + URL: fastly.ToPointer("https://test123.grafana.net"), + Token: fastly.ToPointer("testtoken"), + Index: fastly.ToPointer("{\"label\": \"value\"}"), + }, nil +} + +func getGrafanaCloudLogsError(_ *fastly.GetGrafanaCloudLogsInput) (*fastly.GrafanaCloudLogs, error) { + return nil, errTest +} + +var describeGrafanaCloudLogsOutput = "\n" + strings.TrimSpace(` +Format: %h %l %u %t "%r" %>s %b +Format version: 2 +Index: {"label": "value"} +Message type: classic +Name: logs +Placement: none +Response condition: Prevent default logging +Service ID: 123 +Token: testtoken +URL: https://test123.grafana.net +User: 123456 +Version: 1 +`) + "\n" + +func updateGrafanaCloudLogsOK(i *fastly.UpdateGrafanaCloudLogsInput) (*fastly.GrafanaCloudLogs, error) { + return &fastly.GrafanaCloudLogs{ + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("log"), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + FormatVersion: fastly.ToPointer(2), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + MessageType: fastly.ToPointer("classic"), + Placement: fastly.ToPointer("none"), + User: fastly.ToPointer("123456"), + URL: fastly.ToPointer("https://test123.grafana.net"), + Token: fastly.ToPointer("testtoken"), + Index: fastly.ToPointer("{\"label\": \"value\"}"), + }, nil +} + +func updateGrafanaCloudLogsError(_ *fastly.UpdateGrafanaCloudLogsInput) (*fastly.GrafanaCloudLogs, error) { + return nil, errTest +} + +func deleteGrafanaCloudLogsOK(_ *fastly.DeleteGrafanaCloudLogsInput) error { + return nil +} + +func deleteGrafanaCloudLogsError(_ *fastly.DeleteGrafanaCloudLogsInput) error { + return errTest +} diff --git a/pkg/commands/logging/grafanacloudlogs/grafanacloudlogs_test.go b/pkg/commands/logging/grafanacloudlogs/grafanacloudlogs_test.go new file mode 100644 index 000000000..475dc13f8 --- /dev/null +++ b/pkg/commands/logging/grafanacloudlogs/grafanacloudlogs_test.go @@ -0,0 +1,349 @@ +package grafanacloudlogs_test + +import ( + "bytes" + "testing" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/logging/grafanacloudlogs" + "github.com/fastly/cli/pkg/config" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" +) + +func TestCreateGrafanaCloudLogsInput(t *testing.T) { + for _, testcase := range []struct { + name string + cmd *grafanacloudlogs.CreateCommand + want *fastly.CreateGrafanaCloudLogsInput + wantError string + }{ + { + name: "required values set flag serviceID", + cmd: createCommandRequired(), + want: &fastly.CreateGrafanaCloudLogsInput{ + ServiceID: "123", + ServiceVersion: 4, + Name: fastly.ToPointer("log"), + User: fastly.ToPointer("123456"), + Index: fastly.ToPointer("{\"label\": \"value\"}"), + URL: fastly.ToPointer("https://test123.grafana.net"), + }, + }, + { + name: "all values set flag serviceID", + cmd: createCommandAll(), + want: &fastly.CreateGrafanaCloudLogsInput{ + ServiceID: "123", + ServiceVersion: 4, + FormatVersion: fastly.ToPointer(2), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + MessageType: fastly.ToPointer("classic"), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + Placement: fastly.ToPointer("none"), + Name: fastly.ToPointer("log"), + User: fastly.ToPointer("123456"), + Index: fastly.ToPointer("{\"label\": \"value\"}"), + URL: fastly.ToPointer("https://test123.grafana.net"), + }, + }, + { + name: "error missing serviceID", + cmd: createCommandMissingServiceID(), + want: nil, + wantError: errors.ErrNoServiceID.Error(), + }, + } { + t.Run(testcase.name, func(t *testing.T) { + var bs []byte + out := bytes.NewBuffer(bs) + verboseMode := true + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + AutoCloneFlag: testcase.cmd.AutoClone, + APIClient: testcase.cmd.Globals.APIClient, + Manifest: testcase.cmd.Manifest, + Out: out, + ServiceVersionFlag: testcase.cmd.ServiceVersion, + VerboseMode: verboseMode, + }) + + switch { + case err != nil && testcase.wantError == "": + t.Fatalf("unexpected error getting service details: %v", err) + return + case err != nil && testcase.wantError != "": + testutil.AssertErrorContains(t, err, testcase.wantError) + return + case err == nil && testcase.wantError != "": + t.Fatalf("expected error, have nil (service details: %s, %d)", serviceID, serviceVersion.Number) + case err == nil && testcase.wantError == "": + have, err := testcase.cmd.ConstructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertEqual(t, testcase.want, have) + } + }) + } +} + +func TestUpdateGrafanaCloudLogsInput(t *testing.T) { + scenarios := []struct { + name string + cmd *grafanacloudlogs.UpdateCommand + api mock.API + want *fastly.UpdateGrafanaCloudLogsInput + wantError string + }{ + { + name: "no updates", + cmd: updateCommandNoUpdates(), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + GetGrafanaCloudLogsFn: getGrafanaCloudLogsOK, + }, + want: &fastly.UpdateGrafanaCloudLogsInput{ + ServiceID: "123", + ServiceVersion: 4, + Name: "log", + }, + }, + { + name: "all values set flag serviceID", + cmd: updateCommandAll(), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + GetGrafanaCloudLogsFn: getGrafanaCloudLogsOK, + }, + want: &fastly.UpdateGrafanaCloudLogsInput{ + ServiceID: "123", + ServiceVersion: 4, + Name: "log", + NewName: fastly.ToPointer("new1"), + User: fastly.ToPointer("new3"), + FormatVersion: fastly.ToPointer(3), + Format: fastly.ToPointer("new6"), + ResponseCondition: fastly.ToPointer("new7"), + Placement: fastly.ToPointer("new9"), + MessageType: fastly.ToPointer("new10"), + }, + }, + { + name: "error missing serviceID", + cmd: updateCommandMissingServiceID(), + want: nil, + wantError: errors.ErrNoServiceID.Error(), + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(testcase.name, func(t *testing.T) { + testcase.cmd.Globals.APIClient = testcase.api + + var bs []byte + out := bytes.NewBuffer(bs) + verboseMode := true + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + AutoCloneFlag: testcase.cmd.AutoClone, + APIClient: testcase.api, + Manifest: testcase.cmd.Manifest, + Out: out, + ServiceVersionFlag: testcase.cmd.ServiceVersion, + VerboseMode: verboseMode, + }) + + switch { + case err != nil && testcase.wantError == "": + t.Fatalf("unexpected error getting service details: %v", err) + return + case err != nil && testcase.wantError != "": + testutil.AssertErrorContains(t, err, testcase.wantError) + return + case err == nil && testcase.wantError != "": + t.Fatalf("expected error, have nil (service details: %s, %d)", serviceID, serviceVersion.Number) + case err == nil && testcase.wantError == "": + have, err := testcase.cmd.ConstructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertEqual(t, testcase.want, have) + } + }) + } +} + +func createCommandRequired() *grafanacloudlogs.CreateCommand { + var b bytes.Buffer + + g := global.Data{ + Config: config.File{}, + Env: config.Environment{}, + Output: &b, + } + g.APIClient, _ = mock.APIClient(mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + })("token", "endpoint", false) + + return &grafanacloudlogs.CreateCommand{ + Base: argparser.Base{ + Globals: &g, + }, + Manifest: manifest.Data{ + Flag: manifest.Flag{ + ServiceID: "123", + }, + }, + ServiceVersion: argparser.OptionalServiceVersion{ + OptionalString: argparser.OptionalString{Value: "1"}, + }, + AutoClone: argparser.OptionalAutoClone{ + OptionalBool: argparser.OptionalBool{ + Optional: argparser.Optional{ + WasSet: true, + }, + Value: true, + }, + }, + EndpointName: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "log"}, + User: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "123456"}, + Index: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "{\"label\": \"value\"}"}, + URL: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "https://test123.grafana.net"}, + } +} + +func createCommandAll() *grafanacloudlogs.CreateCommand { + var b bytes.Buffer + + g := global.Data{ + Config: config.File{}, + Env: config.Environment{}, + Output: &b, + } + g.APIClient, _ = mock.APIClient(mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + })("token", "endpoint", false) + + return &grafanacloudlogs.CreateCommand{ + Base: argparser.Base{ + Globals: &g, + }, + Manifest: manifest.Data{ + Flag: manifest.Flag{ + ServiceID: "123", + }, + }, + ServiceVersion: argparser.OptionalServiceVersion{ + OptionalString: argparser.OptionalString{Value: "1"}, + }, + AutoClone: argparser.OptionalAutoClone{ + OptionalBool: argparser.OptionalBool{ + Optional: argparser.Optional{ + WasSet: true, + }, + Value: true, + }, + }, + EndpointName: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "log"}, + Format: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: `%h %l %u %t "%r" %>s %b`}, + FormatVersion: argparser.OptionalInt{Optional: argparser.Optional{WasSet: true}, Value: 2}, + TimestampFormat: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "%Y-%m-%dT%H:%M:%S.000"}, + MessageType: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "classic"}, + ResponseCondition: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "Prevent default logging"}, + Placement: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "none"}, + User: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "123456"}, + Index: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "{\"label\": \"value\"}"}, + URL: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "https://test123.grafana.net"}, + } +} + +func createCommandMissingServiceID() *grafanacloudlogs.CreateCommand { + res := createCommandAll() + res.Manifest = manifest.Data{} + return res +} + +func updateCommandNoUpdates() *grafanacloudlogs.UpdateCommand { + var b bytes.Buffer + + g := global.Data{ + Config: config.File{}, + Env: config.Environment{}, + Output: &b, + } + + return &grafanacloudlogs.UpdateCommand{ + Base: argparser.Base{ + Globals: &g, + }, + Manifest: manifest.Data{ + Flag: manifest.Flag{ + ServiceID: "123", + }, + }, + EndpointName: "log", + ServiceVersion: argparser.OptionalServiceVersion{ + OptionalString: argparser.OptionalString{Value: "1"}, + }, + AutoClone: argparser.OptionalAutoClone{ + OptionalBool: argparser.OptionalBool{ + Optional: argparser.Optional{ + WasSet: true, + }, + Value: true, + }, + }, + } +} + +func updateCommandAll() *grafanacloudlogs.UpdateCommand { + var b bytes.Buffer + + g := global.Data{ + Config: config.File{}, + Env: config.Environment{}, + Output: &b, + } + + return &grafanacloudlogs.UpdateCommand{ + Base: argparser.Base{ + Globals: &g, + }, + Manifest: manifest.Data{ + Flag: manifest.Flag{ + ServiceID: "123", + }, + }, + EndpointName: "log", + ServiceVersion: argparser.OptionalServiceVersion{ + OptionalString: argparser.OptionalString{Value: "1"}, + }, + AutoClone: argparser.OptionalAutoClone{ + OptionalBool: argparser.OptionalBool{ + Optional: argparser.Optional{ + WasSet: true, + }, + Value: true, + }, + }, + NewName: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new1"}, + User: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new3"}, + Format: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new6"}, + FormatVersion: argparser.OptionalInt{Optional: argparser.Optional{WasSet: true}, Value: 3}, + ResponseCondition: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new7"}, + Placement: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new9"}, + MessageType: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new10"}, + } +} + +func updateCommandMissingServiceID() *grafanacloudlogs.UpdateCommand { + res := updateCommandAll() + res.Manifest = manifest.Data{} + return res +} diff --git a/pkg/commands/logging/grafanacloudlogs/list.go b/pkg/commands/logging/grafanacloudlogs/list.go new file mode 100644 index 000000000..667e9b83d --- /dev/null +++ b/pkg/commands/logging/grafanacloudlogs/list.go @@ -0,0 +1,127 @@ +package grafanacloudlogs + +import ( + "fmt" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// ListCommand calls the Fastly API to list Grafana Cloud Logs logging endpoints. +type ListCommand struct { + argparser.Base + argparser.JSONOutput + + Input fastly.ListGrafanaCloudLogsInput + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion +} + +// NewListCommand returns a usable command registered under the parent. +func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { + c := ListCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("list", "List Grafana Cloud Logs endpoints on a Fastly service version") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + return &c +} + +// Exec invokes the application logic for the command. +func (c *ListCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fsterr.ServiceVersion(serviceVersion), + }) + return err + } + + c.Input.ServiceID = serviceID + c.Input.ServiceVersion = fastly.ToValue(serviceVersion.Number) + + o, err := c.Globals.APIClient.ListGrafanaCloudLogs(&c.Input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + if !c.Globals.Verbose() { + tw := text.NewTable(out) + tw.AddHeader("SERVICE", "VERSION", "NAME") + for _, gcs := range o { + tw.AddLine( + fastly.ToValue(gcs.ServiceID), + fastly.ToValue(gcs.ServiceVersion), + fastly.ToValue(gcs.Name), + ) + } + tw.Print() + return nil + } + + fmt.Fprintf(out, "Version: %d\n", c.Input.ServiceVersion) + for i, grafanacloudlogs := range o { + fmt.Fprintf(out, "\tGrafanaCloudLogs %d/%d\n", i+1, len(o)) + fmt.Fprintf(out, "\t\tService ID: %s\n", fastly.ToValue(grafanacloudlogs.ServiceID)) + fmt.Fprintf(out, "\t\tVersion: %d\n", fastly.ToValue(grafanacloudlogs.ServiceVersion)) + fmt.Fprintf(out, "\t\tName: %s\n", fastly.ToValue(grafanacloudlogs.Name)) + fmt.Fprintf(out, "\t\tFormat: %s\n", fastly.ToValue(grafanacloudlogs.Format)) + fmt.Fprintf(out, "\t\tFormat version: %d\n", fastly.ToValue(grafanacloudlogs.FormatVersion)) + fmt.Fprintf(out, "\t\tResponse condition: %s\n", fastly.ToValue(grafanacloudlogs.ResponseCondition)) + fmt.Fprintf(out, "\t\tMessage type: %s\n", fastly.ToValue(grafanacloudlogs.MessageType)) + fmt.Fprintf(out, "\t\tPlacement: %s\n", fastly.ToValue(grafanacloudlogs.Placement)) + fmt.Fprintf(out, "\t\tUser: %s\n", fastly.ToValue(grafanacloudlogs.User)) + fmt.Fprintf(out, "\t\tURL: %s\n", fastly.ToValue(grafanacloudlogs.URL)) + fmt.Fprintf(out, "\t\tToken: %s\n", fastly.ToValue(grafanacloudlogs.Token)) + fmt.Fprintf(out, "\t\tIndex: %s\n", fastly.ToValue(grafanacloudlogs.Index)) + } + fmt.Fprintln(out) + + return nil +} diff --git a/pkg/commands/logging/grafanacloudlogs/root.go b/pkg/commands/logging/grafanacloudlogs/root.go new file mode 100644 index 000000000..5405ba95c --- /dev/null +++ b/pkg/commands/logging/grafanacloudlogs/root.go @@ -0,0 +1,31 @@ +package grafanacloudlogs + +import ( + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" +) + +// RootCommand is the parent command for all subcommands in this package. +// It should be installed under the primary root command. +type RootCommand struct { + argparser.Base + // no flags +} + +// CommandName is the string to be used to invoke this command. +const CommandName = "grafanacloudlogs" + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent argparser.Registerer, g *global.Data) *RootCommand { + var c RootCommand + c.Globals = g + c.CmdClause = parent.Command(CommandName, "Manipulate Fastly service version Grafana Cloud Logs logging endpoints") + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { + panic("unreachable") +} diff --git a/pkg/commands/logging/grafanacloudlogs/update.go b/pkg/commands/logging/grafanacloudlogs/update.go new file mode 100644 index 000000000..3671b2289 --- /dev/null +++ b/pkg/commands/logging/grafanacloudlogs/update.go @@ -0,0 +1,172 @@ +package grafanacloudlogs + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/logging/common" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/text" +) + +// UpdateCommand calls the Fastly API to update a Grafana Cloud Logs logging endpoint. +type UpdateCommand struct { + argparser.Base + Manifest manifest.Data + + // Required. + EndpointName string // Can't shadow argparser.Base method Name(). + ServiceName argparser.OptionalServiceNameID + ServiceVersion argparser.OptionalServiceVersion + User argparser.OptionalString + URL argparser.OptionalString + Index argparser.OptionalString + Token argparser.OptionalString + + // Optional. + AutoClone argparser.OptionalAutoClone + Format argparser.OptionalString + FormatVersion argparser.OptionalInt + MessageType argparser.OptionalString + NewName argparser.OptionalString + Placement argparser.OptionalString + ResponseCondition argparser.OptionalString +} + +// NewUpdateCommand returns a usable command registered under the parent. +func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { + c := UpdateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("update", "Update a Grafana Cloud Logs logging endpoint on a Fastly service version") + + // Required. + c.CmdClause.Flag("name", "The name of the Grafana Cloud Logs logging object").Short('n').Required().StringVar(&c.EndpointName) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.ServiceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.AutoClone.Set, + Dst: &c.AutoClone.Value, + }) + common.Format(c.CmdClause, &c.Format) + common.FormatVersion(c.CmdClause, &c.FormatVersion) + common.MessageType(c.CmdClause, &c.MessageType) + c.CmdClause.Flag("new-name", "New name of the Grafana Cloud Logs logging object").Action(c.NewName.Set).StringVar(&c.NewName.Value) + common.Placement(c.CmdClause, &c.Placement) + common.ResponseCondition(c.CmdClause, &c.ResponseCondition) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.ServiceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.ServiceName.Value, + }) + c.CmdClause.Flag("user", "Your Grafana Cloud Logs User ID.").Action(c.User.Set).StringVar(&c.User.Value) + c.CmdClause.Flag("auth-token", "Your Granana Access Policy Token").Action(c.Token.Set).StringVar(&c.Token.Value) + c.CmdClause.Flag("url", "URL of your Grafana Instance").Action(c.URL.Set).StringVar(&c.URL.Value) + c.CmdClause.Flag("index", "Stream identifier").Action(c.Index.Set).StringVar(&c.Index.Value) + return &c +} + +// ConstructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *UpdateCommand) ConstructInput(serviceID string, serviceVersion int) (*fastly.UpdateGrafanaCloudLogsInput, error) { + input := fastly.UpdateGrafanaCloudLogsInput{ + ServiceID: serviceID, + ServiceVersion: serviceVersion, + Name: c.EndpointName, + } + + if c.Format.WasSet { + input.Format = fastly.ToPointer(argparser.Content(c.Format.Value)) + } + if c.FormatVersion.WasSet { + input.FormatVersion = &c.FormatVersion.Value + } + if c.MessageType.WasSet { + input.MessageType = &c.MessageType.Value + } + if c.NewName.WasSet { + input.NewName = &c.NewName.Value + } + if c.Placement.WasSet { + input.Placement = &c.Placement.Value + } + if c.ResponseCondition.WasSet { + input.ResponseCondition = &c.ResponseCondition.Value + } + if c.User.WasSet { + input.User = &c.User.Value + } + if c.URL.WasSet { + input.URL = &c.URL.Value + } + if c.Token.WasSet { + input.Token = &c.Token.Value + } + if c.Index.WasSet { + input.Index = &c.Index.Value + } + + return &input, nil +} + +// Exec invokes the application logic for the command. +func (c *UpdateCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.AutoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.ServiceName, + ServiceVersionFlag: c.ServiceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + input, err := c.ConstructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + grafanacloudlogs, err := c.Globals.APIClient.UpdateGrafanaCloudLogs(input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + text.Success(out, + "Updated Grafana Cloud Logs logging endpoint %s (service %s version %d)", + fastly.ToValue(grafanacloudlogs.Name), + fastly.ToValue(grafanacloudlogs.ServiceID), + fastly.ToValue(grafanacloudlogs.ServiceVersion), + ) + return nil +} diff --git a/pkg/commands/logging/heroku/create.go b/pkg/commands/logging/heroku/create.go new file mode 100644 index 000000000..f8696143d --- /dev/null +++ b/pkg/commands/logging/heroku/create.go @@ -0,0 +1,157 @@ +package heroku + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/logging/common" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/text" +) + +// CreateCommand calls the Fastly API to create a Heroku logging endpoint. +type CreateCommand struct { + argparser.Base + Manifest manifest.Data + + // Required. + ServiceName argparser.OptionalServiceNameID + ServiceVersion argparser.OptionalServiceVersion + + // Optional. + AutoClone argparser.OptionalAutoClone + EndpointName argparser.OptionalString // Can't shadow argparser.Base method Name(). + Format argparser.OptionalString + FormatVersion argparser.OptionalInt + Placement argparser.OptionalString + ResponseCondition argparser.OptionalString + Token argparser.OptionalString + URL argparser.OptionalString +} + +// NewCreateCommand returns a usable command registered under the parent. +func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { + c := CreateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("create", "Create a Heroku logging endpoint on a Fastly service version").Alias("add") + + // Required. + c.CmdClause.Flag("name", "The name of the Heroku logging object. Used as a primary key for API access").Short('n').Action(c.EndpointName.Set).StringVar(&c.EndpointName.Value) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.ServiceVersion.Value, + Required: true, + }) + + // Optional. + c.CmdClause.Flag("auth-token", "The token to use for authentication (https://devcenter.heroku.com/articles/add-on-partner-log-integration)").Action(c.Token.Set).StringVar(&c.Token.Value) + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.AutoClone.Set, + Dst: &c.AutoClone.Value, + }) + common.Format(c.CmdClause, &c.Format) + common.FormatVersion(c.CmdClause, &c.FormatVersion) + common.Placement(c.CmdClause, &c.Placement) + common.ResponseCondition(c.CmdClause, &c.ResponseCondition) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.ServiceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.ServiceName.Value, + }) + c.CmdClause.Flag("url", "The url to stream logs to").Action(c.URL.Set).StringVar(&c.URL.Value) + return &c +} + +// ConstructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *CreateCommand) ConstructInput(serviceID string, serviceVersion int) (*fastly.CreateHerokuInput, error) { + var input fastly.CreateHerokuInput + + input.ServiceID = serviceID + input.ServiceVersion = serviceVersion + if c.EndpointName.WasSet { + input.Name = &c.EndpointName.Value + } + if c.Token.WasSet { + input.Token = &c.Token.Value + } + if c.URL.WasSet { + input.URL = &c.URL.Value + } + + if c.Format.WasSet { + input.Format = fastly.ToPointer(argparser.Content(c.Format.Value)) + } + + if c.FormatVersion.WasSet { + input.FormatVersion = &c.FormatVersion.Value + } + + if c.ResponseCondition.WasSet { + input.ResponseCondition = &c.ResponseCondition.Value + } + + if c.Placement.WasSet { + input.Placement = &c.Placement.Value + } + + return &input, nil +} + +// Exec invokes the application logic for the command. +func (c *CreateCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.AutoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.ServiceName, + ServiceVersionFlag: c.ServiceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + input, err := c.ConstructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + d, err := c.Globals.APIClient.CreateHeroku(input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + text.Success(out, + "Created Heroku logging endpoint %s (service %s version %d)", + fastly.ToValue(d.Name), + fastly.ToValue(d.ServiceID), + fastly.ToValue(d.ServiceVersion), + ) + return nil +} diff --git a/pkg/commands/logging/heroku/delete.go b/pkg/commands/logging/heroku/delete.go new file mode 100644 index 000000000..c9ce0eb92 --- /dev/null +++ b/pkg/commands/logging/heroku/delete.go @@ -0,0 +1,94 @@ +package heroku + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// DeleteCommand calls the Fastly API to delete a Heroku logging endpoint. +type DeleteCommand struct { + argparser.Base + Input fastly.DeleteHerokuInput + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion + autoClone argparser.OptionalAutoClone +} + +// NewDeleteCommand returns a usable command registered under the parent. +func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { + c := DeleteCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("delete", "Delete a Heroku logging endpoint on a Fastly service version").Alias("remove") + + // Required. + c.CmdClause.Flag("name", "The name of the Heroku logging object").Short('n').Required().StringVar(&c.Input.Name) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.autoClone.Set, + Dst: &c.autoClone.Value, + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + return &c +} + +// Exec invokes the application logic for the command. +func (c *DeleteCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.autoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + c.Input.ServiceID = serviceID + c.Input.ServiceVersion = fastly.ToValue(serviceVersion.Number) + + if err := c.Globals.APIClient.DeleteHeroku(&c.Input); err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + text.Success(out, "Deleted Heroku logging endpoint %s (service %s version %d)", c.Input.Name, c.Input.ServiceID, c.Input.ServiceVersion) + return nil +} diff --git a/pkg/commands/logging/heroku/describe.go b/pkg/commands/logging/heroku/describe.go new file mode 100644 index 000000000..f39cf0885 --- /dev/null +++ b/pkg/commands/logging/heroku/describe.go @@ -0,0 +1,110 @@ +package heroku + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// DescribeCommand calls the Fastly API to describe a Heroku logging endpoint. +type DescribeCommand struct { + argparser.Base + argparser.JSONOutput + + Input fastly.GetHerokuInput + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion +} + +// NewDescribeCommand returns a usable command registered under the parent. +func NewDescribeCommand(parent argparser.Registerer, g *global.Data) *DescribeCommand { + c := DescribeCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("describe", "Show detailed information about a Heroku logging endpoint on a Fastly service version").Alias("get") + + // Required. + c.CmdClause.Flag("name", "The name of the Heroku logging object").Short('n').Required().StringVar(&c.Input.Name) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + return &c +} + +// Exec invokes the application logic for the command. +func (c *DescribeCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fsterr.ServiceVersion(serviceVersion), + }) + return err + } + + c.Input.ServiceID = serviceID + c.Input.ServiceVersion = fastly.ToValue(serviceVersion.Number) + + o, err := c.Globals.APIClient.GetHeroku(&c.Input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + lines := text.Lines{ + "Format version": fastly.ToValue(o.FormatVersion), + "Format": fastly.ToValue(o.Format), + "Name": fastly.ToValue(o.Name), + "Placement": fastly.ToValue(o.Placement), + "Response condition": fastly.ToValue(o.ResponseCondition), + "Token": fastly.ToValue(o.Token), + "URL": fastly.ToValue(o.URL), + "Version": fastly.ToValue(o.ServiceVersion), + } + if !c.Globals.Verbose() { + lines["Service ID"] = fastly.ToValue(o.ServiceID) + } + text.PrintLines(out, lines) + + return nil +} diff --git a/pkg/logging/heroku/doc.go b/pkg/commands/logging/heroku/doc.go similarity index 100% rename from pkg/logging/heroku/doc.go rename to pkg/commands/logging/heroku/doc.go diff --git a/pkg/commands/logging/heroku/heroku_integration_test.go b/pkg/commands/logging/heroku/heroku_integration_test.go new file mode 100644 index 000000000..c59446d0d --- /dev/null +++ b/pkg/commands/logging/heroku/heroku_integration_test.go @@ -0,0 +1,412 @@ +package heroku_test + +import ( + "bytes" + "errors" + "io" + "strings" + "testing" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/app" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" +) + +func TestHerokuCreate(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging heroku create --service-id 123 --version 1 --name log --auth-token abc --url example.com --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + CreateHerokuFn: createHerokuOK, + }, + wantOutput: "Created Heroku logging endpoint log (service 123 version 4)", + }, + { + args: args("logging heroku create --service-id 123 --version 1 --name log --auth-token abc --url example.com --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + CreateHerokuFn: createHerokuError, + }, + wantError: errTest.Error(), + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertStringContains(t, stdout.String(), testcase.wantOutput) + }) + } +} + +func TestHerokuList(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging heroku list --service-id 123 --version 1"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListHerokusFn: listHerokusOK, + }, + wantOutput: listHerokusShortOutput, + }, + { + args: args("logging heroku list --service-id 123 --version 1 --verbose"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListHerokusFn: listHerokusOK, + }, + wantOutput: listHerokusVerboseOutput, + }, + { + args: args("logging heroku list --service-id 123 --version 1 -v"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListHerokusFn: listHerokusOK, + }, + wantOutput: listHerokusVerboseOutput, + }, + { + args: args("logging heroku --verbose list --service-id 123 --version 1"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListHerokusFn: listHerokusOK, + }, + wantOutput: listHerokusVerboseOutput, + }, + { + args: args("logging -v heroku list --service-id 123 --version 1"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListHerokusFn: listHerokusOK, + }, + wantOutput: listHerokusVerboseOutput, + }, + { + args: args("logging heroku list --service-id 123 --version 1"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListHerokusFn: listHerokusError, + }, + wantError: errTest.Error(), + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertString(t, testcase.wantOutput, stdout.String()) + }) + } +} + +func TestHerokuDescribe(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging heroku describe --service-id 123 --version 1"), + wantError: "error parsing arguments: required flag --name not provided", + }, + { + args: args("logging heroku describe --service-id 123 --version 1 --name logs"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + GetHerokuFn: getHerokuError, + }, + wantError: errTest.Error(), + }, + { + args: args("logging heroku describe --service-id 123 --version 1 --name logs"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + GetHerokuFn: getHerokuOK, + }, + wantOutput: describeHerokuOutput, + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertString(t, testcase.wantOutput, stdout.String()) + }) + } +} + +func TestHerokuUpdate(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging heroku update --service-id 123 --version 1 --new-name log"), + wantError: "error parsing arguments: required flag --name not provided", + }, + { + args: args("logging heroku update --service-id 123 --version 1 --name logs --new-name log --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + UpdateHerokuFn: updateHerokuError, + }, + wantError: errTest.Error(), + }, + { + args: args("logging heroku update --service-id 123 --version 1 --name logs --new-name log --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + UpdateHerokuFn: updateHerokuOK, + }, + wantOutput: "Updated Heroku logging endpoint log (service 123 version 4)", + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertStringContains(t, stdout.String(), testcase.wantOutput) + }) + } +} + +func TestHerokuDelete(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging heroku delete --service-id 123 --version 1"), + wantError: "error parsing arguments: required flag --name not provided", + }, + { + args: args("logging heroku delete --service-id 123 --version 1 --name logs --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + DeleteHerokuFn: deleteHerokuError, + }, + wantError: errTest.Error(), + }, + { + args: args("logging heroku delete --service-id 123 --version 1 --name logs --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + DeleteHerokuFn: deleteHerokuOK, + }, + wantOutput: "Deleted Heroku logging endpoint logs (service 123 version 4)", + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertStringContains(t, stdout.String(), testcase.wantOutput) + }) + } +} + +var errTest = errors.New("fixture error") + +func createHerokuOK(i *fastly.CreateHerokuInput) (*fastly.Heroku, error) { + s := fastly.Heroku{ + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + } + + if i.Name != nil { + s.Name = i.Name + } + + return &s, nil +} + +func createHerokuError(_ *fastly.CreateHerokuInput) (*fastly.Heroku, error) { + return nil, errTest +} + +func listHerokusOK(i *fastly.ListHerokusInput) ([]*fastly.Heroku, error) { + return []*fastly.Heroku{ + { + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("logs"), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + FormatVersion: fastly.ToPointer(2), + URL: fastly.ToPointer("example.com"), + Token: fastly.ToPointer("abc"), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + Placement: fastly.ToPointer("none"), + }, + { + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("analytics"), + URL: fastly.ToPointer("bar.com"), + Token: fastly.ToPointer("abc"), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + FormatVersion: fastly.ToPointer(2), + Placement: fastly.ToPointer("none"), + }, + }, nil +} + +func listHerokusError(_ *fastly.ListHerokusInput) ([]*fastly.Heroku, error) { + return nil, errTest +} + +var listHerokusShortOutput = strings.TrimSpace(` +SERVICE VERSION NAME +123 1 logs +123 1 analytics +`) + "\n" + +var listHerokusVerboseOutput = strings.TrimSpace(` +Fastly API endpoint: https://api.fastly.com +Fastly API token provided via config file (profile: user) + +Service ID (via --service-id): 123 + +Version: 1 + Heroku 1/2 + Service ID: 123 + Version: 1 + Name: logs + URL: example.com + Token: abc + Format: %h %l %u %t "%r" %>s %b + Format version: 2 + Response condition: Prevent default logging + Placement: none + Heroku 2/2 + Service ID: 123 + Version: 1 + Name: analytics + URL: bar.com + Token: abc + Format: %h %l %u %t "%r" %>s %b + Format version: 2 + Response condition: Prevent default logging + Placement: none +`) + "\n\n" + +func getHerokuOK(i *fastly.GetHerokuInput) (*fastly.Heroku, error) { + return &fastly.Heroku{ + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("logs"), + URL: fastly.ToPointer("example.com"), + Token: fastly.ToPointer("abc"), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + FormatVersion: fastly.ToPointer(2), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + Placement: fastly.ToPointer("none"), + }, nil +} + +func getHerokuError(_ *fastly.GetHerokuInput) (*fastly.Heroku, error) { + return nil, errTest +} + +var describeHerokuOutput = "\n" + strings.TrimSpace(` +Format: %h %l %u %t "%r" %>s %b +Format version: 2 +Name: logs +Placement: none +Response condition: Prevent default logging +Service ID: 123 +Token: abc +URL: example.com +Version: 1 +`) + "\n" + +func updateHerokuOK(i *fastly.UpdateHerokuInput) (*fastly.Heroku, error) { + return &fastly.Heroku{ + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("log"), + URL: fastly.ToPointer("example.com"), + Token: fastly.ToPointer("abc"), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + FormatVersion: fastly.ToPointer(2), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + Placement: fastly.ToPointer("none"), + }, nil +} + +func updateHerokuError(_ *fastly.UpdateHerokuInput) (*fastly.Heroku, error) { + return nil, errTest +} + +func deleteHerokuOK(_ *fastly.DeleteHerokuInput) error { + return nil +} + +func deleteHerokuError(_ *fastly.DeleteHerokuInput) error { + return errTest +} diff --git a/pkg/commands/logging/heroku/heroku_test.go b/pkg/commands/logging/heroku/heroku_test.go new file mode 100644 index 000000000..582420180 --- /dev/null +++ b/pkg/commands/logging/heroku/heroku_test.go @@ -0,0 +1,342 @@ +package heroku_test + +import ( + "bytes" + "testing" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/logging/heroku" + "github.com/fastly/cli/pkg/config" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" +) + +func TestCreateHerokuInput(t *testing.T) { + for _, testcase := range []struct { + name string + cmd *heroku.CreateCommand + want *fastly.CreateHerokuInput + wantError string + }{ + { + name: "required values set flag serviceID", + cmd: createCommandRequired(), + want: &fastly.CreateHerokuInput{ + ServiceID: "123", + ServiceVersion: 4, + Name: fastly.ToPointer("log"), + Token: fastly.ToPointer("tkn"), + URL: fastly.ToPointer("example.com"), + }, + }, + { + name: "all values set flag serviceID", + cmd: createCommandAll(), + want: &fastly.CreateHerokuInput{ + ServiceID: "123", + ServiceVersion: 4, + Name: fastly.ToPointer("log"), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + FormatVersion: fastly.ToPointer(2), + Token: fastly.ToPointer("tkn"), + URL: fastly.ToPointer("example.com"), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + Placement: fastly.ToPointer("none"), + }, + }, + { + name: "error missing serviceID", + cmd: createCommandMissingServiceID(), + want: nil, + wantError: errors.ErrNoServiceID.Error(), + }, + } { + t.Run(testcase.name, func(t *testing.T) { + var bs []byte + out := bytes.NewBuffer(bs) + verboseMode := true + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + AutoCloneFlag: testcase.cmd.AutoClone, + APIClient: testcase.cmd.Globals.APIClient, + Manifest: testcase.cmd.Manifest, + Out: out, + ServiceVersionFlag: testcase.cmd.ServiceVersion, + VerboseMode: verboseMode, + }) + + switch { + case err != nil && testcase.wantError == "": + t.Fatalf("unexpected error getting service details: %v", err) + return + case err != nil && testcase.wantError != "": + testutil.AssertErrorContains(t, err, testcase.wantError) + return + case err == nil && testcase.wantError != "": + t.Fatalf("expected error, have nil (service details: %s, %d)", serviceID, serviceVersion.Number) + case err == nil && testcase.wantError == "": + have, err := testcase.cmd.ConstructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertEqual(t, testcase.want, have) + } + }) + } +} + +func TestUpdateHerokuInput(t *testing.T) { + scenarios := []struct { + name string + cmd *heroku.UpdateCommand + api mock.API + want *fastly.UpdateHerokuInput + wantError string + }{ + { + name: "no updates", + cmd: updateCommandNoUpdates(), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + GetHerokuFn: getHerokuOK, + }, + want: &fastly.UpdateHerokuInput{ + ServiceID: "123", + ServiceVersion: 4, + Name: "log", + }, + }, + { + name: "all values set flag serviceID", + cmd: updateCommandAll(), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + GetHerokuFn: getHerokuOK, + }, + want: &fastly.UpdateHerokuInput{ + ServiceID: "123", + ServiceVersion: 4, + Name: "log", + NewName: fastly.ToPointer("new1"), + Format: fastly.ToPointer("new2"), + FormatVersion: fastly.ToPointer(3), + Token: fastly.ToPointer("new3"), + URL: fastly.ToPointer("new4"), + ResponseCondition: fastly.ToPointer("new5"), + Placement: fastly.ToPointer("new6"), + }, + }, + { + name: "error missing serviceID", + cmd: updateCommandMissingServiceID(), + want: nil, + wantError: errors.ErrNoServiceID.Error(), + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(testcase.name, func(t *testing.T) { + testcase.cmd.Globals.APIClient = testcase.api + + var bs []byte + out := bytes.NewBuffer(bs) + verboseMode := true + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + AutoCloneFlag: testcase.cmd.AutoClone, + APIClient: testcase.api, + Manifest: testcase.cmd.Manifest, + Out: out, + ServiceVersionFlag: testcase.cmd.ServiceVersion, + VerboseMode: verboseMode, + }) + + switch { + case err != nil && testcase.wantError == "": + t.Fatalf("unexpected error getting service details: %v", err) + return + case err != nil && testcase.wantError != "": + testutil.AssertErrorContains(t, err, testcase.wantError) + return + case err == nil && testcase.wantError != "": + t.Fatalf("expected error, have nil (service details: %s, %d)", serviceID, serviceVersion.Number) + case err == nil && testcase.wantError == "": + have, err := testcase.cmd.ConstructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertEqual(t, testcase.want, have) + } + }) + } +} + +func createCommandRequired() *heroku.CreateCommand { + var b bytes.Buffer + + g := global.Data{ + Config: config.File{}, + Env: config.Environment{}, + Output: &b, + } + g.APIClient, _ = mock.APIClient(mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + })("token", "endpoint", false) + + return &heroku.CreateCommand{ + Base: argparser.Base{ + Globals: &g, + }, + Manifest: manifest.Data{ + Flag: manifest.Flag{ + ServiceID: "123", + }, + }, + EndpointName: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "log"}, + Token: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "tkn"}, + URL: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "example.com"}, + ServiceVersion: argparser.OptionalServiceVersion{ + OptionalString: argparser.OptionalString{Value: "1"}, + }, + AutoClone: argparser.OptionalAutoClone{ + OptionalBool: argparser.OptionalBool{ + Optional: argparser.Optional{ + WasSet: true, + }, + Value: true, + }, + }, + } +} + +func createCommandAll() *heroku.CreateCommand { + var b bytes.Buffer + + g := global.Data{ + Config: config.File{}, + Env: config.Environment{}, + Output: &b, + } + g.APIClient, _ = mock.APIClient(mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + })("token", "endpoint", false) + + return &heroku.CreateCommand{ + Base: argparser.Base{ + Globals: &g, + }, + Manifest: manifest.Data{ + Flag: manifest.Flag{ + ServiceID: "123", + }, + }, + EndpointName: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "log"}, + Token: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "tkn"}, + URL: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "example.com"}, + ServiceVersion: argparser.OptionalServiceVersion{ + OptionalString: argparser.OptionalString{Value: "1"}, + }, + AutoClone: argparser.OptionalAutoClone{ + OptionalBool: argparser.OptionalBool{ + Optional: argparser.Optional{ + WasSet: true, + }, + Value: true, + }, + }, + Format: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: `%h %l %u %t "%r" %>s %b`}, + FormatVersion: argparser.OptionalInt{Optional: argparser.Optional{WasSet: true}, Value: 2}, + ResponseCondition: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "Prevent default logging"}, + Placement: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "none"}, + } +} + +func createCommandMissingServiceID() *heroku.CreateCommand { + res := createCommandAll() + res.Manifest = manifest.Data{} + return res +} + +func updateCommandNoUpdates() *heroku.UpdateCommand { + var b bytes.Buffer + + g := global.Data{ + Config: config.File{}, + Env: config.Environment{}, + Output: &b, + } + + return &heroku.UpdateCommand{ + Base: argparser.Base{ + Globals: &g, + }, + Manifest: manifest.Data{ + Flag: manifest.Flag{ + ServiceID: "123", + }, + }, + EndpointName: "log", + ServiceVersion: argparser.OptionalServiceVersion{ + OptionalString: argparser.OptionalString{Value: "1"}, + }, + AutoClone: argparser.OptionalAutoClone{ + OptionalBool: argparser.OptionalBool{ + Optional: argparser.Optional{ + WasSet: true, + }, + Value: true, + }, + }, + } +} + +func updateCommandAll() *heroku.UpdateCommand { + var b bytes.Buffer + + g := global.Data{ + Config: config.File{}, + Env: config.Environment{}, + Output: &b, + } + + return &heroku.UpdateCommand{ + Base: argparser.Base{ + Globals: &g, + }, + Manifest: manifest.Data{ + Flag: manifest.Flag{ + ServiceID: "123", + }, + }, + EndpointName: "log", + ServiceVersion: argparser.OptionalServiceVersion{ + OptionalString: argparser.OptionalString{Value: "1"}, + }, + AutoClone: argparser.OptionalAutoClone{ + OptionalBool: argparser.OptionalBool{ + Optional: argparser.Optional{ + WasSet: true, + }, + Value: true, + }, + }, + NewName: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new1"}, + Format: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new2"}, + FormatVersion: argparser.OptionalInt{Optional: argparser.Optional{WasSet: true}, Value: 3}, + Token: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new3"}, + URL: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new4"}, + ResponseCondition: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new5"}, + Placement: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new6"}, + } +} + +func updateCommandMissingServiceID() *heroku.UpdateCommand { + res := updateCommandAll() + res.Manifest = manifest.Data{} + return res +} diff --git a/pkg/commands/logging/heroku/list.go b/pkg/commands/logging/heroku/list.go new file mode 100644 index 000000000..8e57a9dc1 --- /dev/null +++ b/pkg/commands/logging/heroku/list.go @@ -0,0 +1,124 @@ +package heroku + +import ( + "fmt" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// ListCommand calls the Fastly API to list Heroku logging endpoints. +type ListCommand struct { + argparser.Base + argparser.JSONOutput + + Input fastly.ListHerokusInput + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion +} + +// NewListCommand returns a usable command registered under the parent. +func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { + c := ListCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("list", "List Heroku endpoints on a Fastly service version") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + return &c +} + +// Exec invokes the application logic for the command. +func (c *ListCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fsterr.ServiceVersion(serviceVersion), + }) + return err + } + + c.Input.ServiceID = serviceID + c.Input.ServiceVersion = fastly.ToValue(serviceVersion.Number) + + o, err := c.Globals.APIClient.ListHerokus(&c.Input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + if !c.Globals.Verbose() { + tw := text.NewTable(out) + tw.AddHeader("SERVICE", "VERSION", "NAME") + for _, heroku := range o { + tw.AddLine( + fastly.ToValue(heroku.ServiceID), + fastly.ToValue(heroku.ServiceVersion), + fastly.ToValue(heroku.Name), + ) + } + tw.Print() + return nil + } + + fmt.Fprintf(out, "Version: %d\n", c.Input.ServiceVersion) + for i, heroku := range o { + fmt.Fprintf(out, "\tHeroku %d/%d\n", i+1, len(o)) + fmt.Fprintf(out, "\t\tService ID: %s\n", fastly.ToValue(heroku.ServiceID)) + fmt.Fprintf(out, "\t\tVersion: %d\n", fastly.ToValue(heroku.ServiceVersion)) + fmt.Fprintf(out, "\t\tName: %s\n", fastly.ToValue(heroku.Name)) + fmt.Fprintf(out, "\t\tURL: %s\n", fastly.ToValue(heroku.URL)) + fmt.Fprintf(out, "\t\tToken: %s\n", fastly.ToValue(heroku.Token)) + fmt.Fprintf(out, "\t\tFormat: %s\n", fastly.ToValue(heroku.Format)) + fmt.Fprintf(out, "\t\tFormat version: %d\n", fastly.ToValue(heroku.FormatVersion)) + fmt.Fprintf(out, "\t\tResponse condition: %s\n", fastly.ToValue(heroku.ResponseCondition)) + fmt.Fprintf(out, "\t\tPlacement: %s\n", fastly.ToValue(heroku.Placement)) + } + fmt.Fprintln(out) + + return nil +} diff --git a/pkg/commands/logging/heroku/root.go b/pkg/commands/logging/heroku/root.go new file mode 100644 index 000000000..a36d9f093 --- /dev/null +++ b/pkg/commands/logging/heroku/root.go @@ -0,0 +1,31 @@ +package heroku + +import ( + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" +) + +// RootCommand is the parent command for all subcommands in this package. +// It should be installed under the primary root command. +type RootCommand struct { + argparser.Base + // no flags +} + +// CommandName is the string to be used to invoke this command. +const CommandName = "heroku" + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent argparser.Registerer, g *global.Data) *RootCommand { + var c RootCommand + c.Globals = g + c.CmdClause = parent.Command(CommandName, "Manipulate Fastly service version Heroku logging endpoints") + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { + panic("unreachable") +} diff --git a/pkg/commands/logging/heroku/update.go b/pkg/commands/logging/heroku/update.go new file mode 100644 index 000000000..f3b43fc80 --- /dev/null +++ b/pkg/commands/logging/heroku/update.go @@ -0,0 +1,163 @@ +package heroku + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/logging/common" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/text" +) + +// UpdateCommand calls the Fastly API to update a Heroku logging endpoint. +type UpdateCommand struct { + argparser.Base + Manifest manifest.Data + + // Required. + EndpointName string // Can't shadow argparser.Base method Name(). + ServiceName argparser.OptionalServiceNameID + ServiceVersion argparser.OptionalServiceVersion + + // Optional. + AutoClone argparser.OptionalAutoClone + NewName argparser.OptionalString + Format argparser.OptionalString + FormatVersion argparser.OptionalInt + URL argparser.OptionalString + Token argparser.OptionalString + ResponseCondition argparser.OptionalString + Placement argparser.OptionalString +} + +// NewUpdateCommand returns a usable command registered under the parent. +func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { + c := UpdateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("update", "Update a Heroku logging endpoint on a Fastly service version") + + // Required. + c.CmdClause.Flag("name", "The name of the Heroku logging object").Short('n').Required().StringVar(&c.EndpointName) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.ServiceVersion.Value, + Required: true, + }) + + // Optional. + c.CmdClause.Flag("auth-token", "The token to use for authentication (https://devcenter.heroku.com/articles/add-on-partner-log-integration)").Action(c.Token.Set).StringVar(&c.Token.Value) + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.AutoClone.Set, + Dst: &c.AutoClone.Value, + }) + common.Format(c.CmdClause, &c.Format) + common.FormatVersion(c.CmdClause, &c.FormatVersion) + c.CmdClause.Flag("new-name", "New name of the Heroku logging object").Action(c.NewName.Set).StringVar(&c.NewName.Value) + common.Placement(c.CmdClause, &c.Placement) + common.ResponseCondition(c.CmdClause, &c.ResponseCondition) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.ServiceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.ServiceName.Value, + }) + c.CmdClause.Flag("url", "The url to stream logs to").Action(c.URL.Set).StringVar(&c.URL.Value) + return &c +} + +// ConstructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *UpdateCommand) ConstructInput(serviceID string, serviceVersion int) (*fastly.UpdateHerokuInput, error) { + input := fastly.UpdateHerokuInput{ + ServiceID: serviceID, + ServiceVersion: serviceVersion, + Name: c.EndpointName, + } + + if c.NewName.WasSet { + input.NewName = &c.NewName.Value + } + + if c.Format.WasSet { + input.Format = fastly.ToPointer(argparser.Content(c.Format.Value)) + } + + if c.FormatVersion.WasSet { + input.FormatVersion = &c.FormatVersion.Value + } + + if c.Token.WasSet { + input.Token = &c.Token.Value + } + + if c.URL.WasSet { + input.URL = &c.URL.Value + } + + if c.ResponseCondition.WasSet { + input.ResponseCondition = &c.ResponseCondition.Value + } + + if c.Placement.WasSet { + input.Placement = &c.Placement.Value + } + + return &input, nil +} + +// Exec invokes the application logic for the command. +func (c *UpdateCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.AutoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.ServiceName, + ServiceVersionFlag: c.ServiceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + input, err := c.ConstructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + heroku, err := c.Globals.APIClient.UpdateHeroku(input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + text.Success(out, + "Updated Heroku logging endpoint %s (service %s version %d)", + fastly.ToValue(heroku.Name), + fastly.ToValue(heroku.ServiceID), + fastly.ToValue(heroku.ServiceVersion), + ) + return nil +} diff --git a/pkg/commands/logging/honeycomb/create.go b/pkg/commands/logging/honeycomb/create.go new file mode 100644 index 000000000..4756ba491 --- /dev/null +++ b/pkg/commands/logging/honeycomb/create.go @@ -0,0 +1,157 @@ +package honeycomb + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/logging/common" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/text" +) + +// CreateCommand calls the Fastly API to create a Honeycomb logging endpoint. +type CreateCommand struct { + argparser.Base + Manifest manifest.Data + + // Required. + ServiceName argparser.OptionalServiceNameID + ServiceVersion argparser.OptionalServiceVersion + + // Optional. + AutoClone argparser.OptionalAutoClone + Dataset argparser.OptionalString + EndpointName argparser.OptionalString // Can't shadow argparser.Base method Name(). + Format argparser.OptionalString + FormatVersion argparser.OptionalInt + Placement argparser.OptionalString + ResponseCondition argparser.OptionalString + Token argparser.OptionalString +} + +// NewCreateCommand returns a usable command registered under the parent. +func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { + c := CreateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("create", "Create a Honeycomb logging endpoint on a Fastly service version").Alias("add") + + // Required. + c.CmdClause.Flag("name", "The name of the Honeycomb logging object. Used as a primary key for API access").Short('n').Action(c.EndpointName.Set).StringVar(&c.EndpointName.Value) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.ServiceVersion.Value, + Required: true, + }) + + // Optional. + c.CmdClause.Flag("auth-token", "The Write Key from the Account page of your Honeycomb account").Action(c.Token.Set).StringVar(&c.Token.Value) + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.AutoClone.Set, + Dst: &c.AutoClone.Value, + }) + c.CmdClause.Flag("dataset", "The Honeycomb Dataset you want to log to").Action(c.Dataset.Set).StringVar(&c.Dataset.Value) + common.Format(c.CmdClause, &c.Format) + common.FormatVersion(c.CmdClause, &c.FormatVersion) + common.ResponseCondition(c.CmdClause, &c.ResponseCondition) + common.Placement(c.CmdClause, &c.Placement) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.ServiceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.ServiceName.Value, + }) + return &c +} + +// ConstructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *CreateCommand) ConstructInput(serviceID string, serviceVersion int) (*fastly.CreateHoneycombInput, error) { + var input fastly.CreateHoneycombInput + + input.ServiceID = serviceID + input.ServiceVersion = serviceVersion + if c.EndpointName.WasSet { + input.Name = &c.EndpointName.Value + } + if c.Token.WasSet { + input.Token = &c.Token.Value + } + if c.Dataset.WasSet { + input.Dataset = &c.Dataset.Value + } + + if c.Format.WasSet { + input.Format = fastly.ToPointer(argparser.Content(c.Format.Value)) + } + + if c.FormatVersion.WasSet { + input.FormatVersion = &c.FormatVersion.Value + } + + if c.ResponseCondition.WasSet { + input.ResponseCondition = &c.ResponseCondition.Value + } + + if c.Placement.WasSet { + input.Placement = &c.Placement.Value + } + + return &input, nil +} + +// Exec invokes the application logic for the command. +func (c *CreateCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.AutoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.ServiceName, + ServiceVersionFlag: c.ServiceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + input, err := c.ConstructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + d, err := c.Globals.APIClient.CreateHoneycomb(input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + text.Success(out, + "Created Honeycomb logging endpoint %s (service %s version %d)", + fastly.ToValue(d.Name), + fastly.ToValue(d.ServiceID), + fastly.ToValue(d.ServiceVersion), + ) + return nil +} diff --git a/pkg/commands/logging/honeycomb/delete.go b/pkg/commands/logging/honeycomb/delete.go new file mode 100644 index 000000000..6484cffb1 --- /dev/null +++ b/pkg/commands/logging/honeycomb/delete.go @@ -0,0 +1,94 @@ +package honeycomb + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// DeleteCommand calls the Fastly API to delete a Honeycomb logging endpoint. +type DeleteCommand struct { + argparser.Base + Input fastly.DeleteHoneycombInput + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion + autoClone argparser.OptionalAutoClone +} + +// NewDeleteCommand returns a usable command registered under the parent. +func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { + c := DeleteCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("delete", "Delete a Honeycomb logging endpoint on a Fastly service version").Alias("remove") + + // Required. + c.CmdClause.Flag("name", "The name of the Honeycomb logging object").Short('n').Required().StringVar(&c.Input.Name) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.autoClone.Set, + Dst: &c.autoClone.Value, + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + return &c +} + +// Exec invokes the application logic for the command. +func (c *DeleteCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.autoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + c.Input.ServiceID = serviceID + c.Input.ServiceVersion = fastly.ToValue(serviceVersion.Number) + + if err := c.Globals.APIClient.DeleteHoneycomb(&c.Input); err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + text.Success(out, "Deleted Honeycomb logging endpoint %s (service %s version %d)", c.Input.Name, c.Input.ServiceID, c.Input.ServiceVersion) + return nil +} diff --git a/pkg/commands/logging/honeycomb/describe.go b/pkg/commands/logging/honeycomb/describe.go new file mode 100644 index 000000000..3b4eeed86 --- /dev/null +++ b/pkg/commands/logging/honeycomb/describe.go @@ -0,0 +1,110 @@ +package honeycomb + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// DescribeCommand calls the Fastly API to describe a Honeycomb logging endpoint. +type DescribeCommand struct { + argparser.Base + argparser.JSONOutput + + Input fastly.GetHoneycombInput + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion +} + +// NewDescribeCommand returns a usable command registered under the parent. +func NewDescribeCommand(parent argparser.Registerer, g *global.Data) *DescribeCommand { + c := DescribeCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("describe", "Show detailed information about a Honeycomb logging endpoint on a Fastly service version").Alias("get") + + // Required. + c.CmdClause.Flag("name", "The name of the Honeycomb logging object").Short('n').Required().StringVar(&c.Input.Name) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + return &c +} + +// Exec invokes the application logic for the command. +func (c *DescribeCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fsterr.ServiceVersion(serviceVersion), + }) + return err + } + + c.Input.ServiceID = serviceID + c.Input.ServiceVersion = fastly.ToValue(serviceVersion.Number) + + o, err := c.Globals.APIClient.GetHoneycomb(&c.Input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + lines := text.Lines{ + "Dataset": fastly.ToValue(o.Dataset), + "Format version": fastly.ToValue(o.FormatVersion), + "Format": fastly.ToValue(o.Format), + "Name": fastly.ToValue(o.Name), + "Placement": fastly.ToValue(o.Placement), + "Response condition": fastly.ToValue(o.ResponseCondition), + "Token": fastly.ToValue(o.Token), + "Version": fastly.ToValue(o.ServiceVersion), + } + if !c.Globals.Verbose() { + lines["Service ID"] = fastly.ToValue(o.ServiceID) + } + text.PrintLines(out, lines) + + return nil +} diff --git a/pkg/logging/honeycomb/doc.go b/pkg/commands/logging/honeycomb/doc.go similarity index 100% rename from pkg/logging/honeycomb/doc.go rename to pkg/commands/logging/honeycomb/doc.go diff --git a/pkg/commands/logging/honeycomb/honeycomb_integration_test.go b/pkg/commands/logging/honeycomb/honeycomb_integration_test.go new file mode 100644 index 000000000..7f068fe75 --- /dev/null +++ b/pkg/commands/logging/honeycomb/honeycomb_integration_test.go @@ -0,0 +1,412 @@ +package honeycomb_test + +import ( + "bytes" + "errors" + "io" + "strings" + "testing" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/app" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" +) + +func TestHoneycombCreate(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging honeycomb create --service-id 123 --version 1 --name log --auth-token abc --dataset log --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + CreateHoneycombFn: createHoneycombOK, + }, + wantOutput: "Created Honeycomb logging endpoint log (service 123 version 4)", + }, + { + args: args("logging honeycomb create --service-id 123 --version 1 --name log --auth-token abc --dataset log --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + CreateHoneycombFn: createHoneycombError, + }, + wantError: errTest.Error(), + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertStringContains(t, stdout.String(), testcase.wantOutput) + }) + } +} + +func TestHoneycombList(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging honeycomb list --service-id 123 --version 1"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListHoneycombsFn: listHoneycombsOK, + }, + wantOutput: listHoneycombsShortOutput, + }, + { + args: args("logging honeycomb list --service-id 123 --version 1 --verbose"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListHoneycombsFn: listHoneycombsOK, + }, + wantOutput: listHoneycombsVerboseOutput, + }, + { + args: args("logging honeycomb list --service-id 123 --version 1 -v"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListHoneycombsFn: listHoneycombsOK, + }, + wantOutput: listHoneycombsVerboseOutput, + }, + { + args: args("logging honeycomb --verbose list --service-id 123 --version 1"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListHoneycombsFn: listHoneycombsOK, + }, + wantOutput: listHoneycombsVerboseOutput, + }, + { + args: args("logging -v honeycomb list --service-id 123 --version 1"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListHoneycombsFn: listHoneycombsOK, + }, + wantOutput: listHoneycombsVerboseOutput, + }, + { + args: args("logging honeycomb list --service-id 123 --version 1"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListHoneycombsFn: listHoneycombsError, + }, + wantError: errTest.Error(), + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertString(t, testcase.wantOutput, stdout.String()) + }) + } +} + +func TestHoneycombDescribe(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging honeycomb describe --service-id 123 --version 1"), + wantError: "error parsing arguments: required flag --name not provided", + }, + { + args: args("logging honeycomb describe --service-id 123 --version 1 --name logs"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + GetHoneycombFn: getHoneycombError, + }, + wantError: errTest.Error(), + }, + { + args: args("logging honeycomb describe --service-id 123 --version 1 --name logs"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + GetHoneycombFn: getHoneycombOK, + }, + wantOutput: describeHoneycombOutput, + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertString(t, testcase.wantOutput, stdout.String()) + }) + } +} + +func TestHoneycombUpdate(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging honeycomb update --service-id 123 --version 1 --new-name log"), + wantError: "error parsing arguments: required flag --name not provided", + }, + { + args: args("logging honeycomb update --service-id 123 --version 1 --name logs --new-name log --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + UpdateHoneycombFn: updateHoneycombError, + }, + wantError: errTest.Error(), + }, + { + args: args("logging honeycomb update --service-id 123 --version 1 --name logs --new-name log --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + UpdateHoneycombFn: updateHoneycombOK, + }, + wantOutput: "Updated Honeycomb logging endpoint log (service 123 version 4)", + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertStringContains(t, stdout.String(), testcase.wantOutput) + }) + } +} + +func TestHoneycombDelete(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging honeycomb delete --service-id 123 --version 1"), + wantError: "error parsing arguments: required flag --name not provided", + }, + { + args: args("logging honeycomb delete --service-id 123 --version 1 --name logs --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + DeleteHoneycombFn: deleteHoneycombError, + }, + wantError: errTest.Error(), + }, + { + args: args("logging honeycomb delete --service-id 123 --version 1 --name logs --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + DeleteHoneycombFn: deleteHoneycombOK, + }, + wantOutput: "Deleted Honeycomb logging endpoint logs (service 123 version 4)", + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertStringContains(t, stdout.String(), testcase.wantOutput) + }) + } +} + +var errTest = errors.New("fixture error") + +func createHoneycombOK(i *fastly.CreateHoneycombInput) (*fastly.Honeycomb, error) { + s := fastly.Honeycomb{ + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + } + + if i.Name != nil { + s.Name = i.Name + } + + return &s, nil +} + +func createHoneycombError(_ *fastly.CreateHoneycombInput) (*fastly.Honeycomb, error) { + return nil, errTest +} + +func listHoneycombsOK(i *fastly.ListHoneycombsInput) ([]*fastly.Honeycomb, error) { + return []*fastly.Honeycomb{ + { + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("logs"), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + FormatVersion: fastly.ToPointer(2), + Dataset: fastly.ToPointer("log"), + Token: fastly.ToPointer("tkn"), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + Placement: fastly.ToPointer("none"), + }, + { + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("analytics"), + Dataset: fastly.ToPointer("log"), + Token: fastly.ToPointer("tkn"), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + FormatVersion: fastly.ToPointer(2), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + Placement: fastly.ToPointer("none"), + }, + }, nil +} + +func listHoneycombsError(_ *fastly.ListHoneycombsInput) ([]*fastly.Honeycomb, error) { + return nil, errTest +} + +var listHoneycombsShortOutput = strings.TrimSpace(` +SERVICE VERSION NAME +123 1 logs +123 1 analytics +`) + "\n" + +var listHoneycombsVerboseOutput = strings.TrimSpace(` +Fastly API endpoint: https://api.fastly.com +Fastly API token provided via config file (profile: user) + +Service ID (via --service-id): 123 + +Version: 1 + Honeycomb 1/2 + Service ID: 123 + Version: 1 + Name: logs + Dataset: log + Token: tkn + Format: %h %l %u %t "%r" %>s %b + Format version: 2 + Response condition: Prevent default logging + Placement: none + Honeycomb 2/2 + Service ID: 123 + Version: 1 + Name: analytics + Dataset: log + Token: tkn + Format: %h %l %u %t "%r" %>s %b + Format version: 2 + Response condition: Prevent default logging + Placement: none +`) + "\n\n" + +func getHoneycombOK(i *fastly.GetHoneycombInput) (*fastly.Honeycomb, error) { + return &fastly.Honeycomb{ + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("logs"), + Dataset: fastly.ToPointer("log"), + Token: fastly.ToPointer("tkn"), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + FormatVersion: fastly.ToPointer(2), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + Placement: fastly.ToPointer("none"), + }, nil +} + +func getHoneycombError(_ *fastly.GetHoneycombInput) (*fastly.Honeycomb, error) { + return nil, errTest +} + +var describeHoneycombOutput = "\n" + strings.TrimSpace(` +Dataset: log +Format: %h %l %u %t "%r" %>s %b +Format version: 2 +Name: logs +Placement: none +Response condition: Prevent default logging +Service ID: 123 +Token: tkn +Version: 1 +`) + "\n" + +func updateHoneycombOK(i *fastly.UpdateHoneycombInput) (*fastly.Honeycomb, error) { + return &fastly.Honeycomb{ + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("log"), + Dataset: fastly.ToPointer("log"), + Token: fastly.ToPointer("tkn"), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + FormatVersion: fastly.ToPointer(2), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + Placement: fastly.ToPointer("none"), + }, nil +} + +func updateHoneycombError(_ *fastly.UpdateHoneycombInput) (*fastly.Honeycomb, error) { + return nil, errTest +} + +func deleteHoneycombOK(_ *fastly.DeleteHoneycombInput) error { + return nil +} + +func deleteHoneycombError(_ *fastly.DeleteHoneycombInput) error { + return errTest +} diff --git a/pkg/commands/logging/honeycomb/honeycomb_test.go b/pkg/commands/logging/honeycomb/honeycomb_test.go new file mode 100644 index 000000000..5f029c929 --- /dev/null +++ b/pkg/commands/logging/honeycomb/honeycomb_test.go @@ -0,0 +1,342 @@ +package honeycomb_test + +import ( + "bytes" + "testing" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/logging/honeycomb" + "github.com/fastly/cli/pkg/config" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" +) + +func TestCreateHoneycombInput(t *testing.T) { + for _, testcase := range []struct { + name string + cmd *honeycomb.CreateCommand + want *fastly.CreateHoneycombInput + wantError string + }{ + { + name: "required values set flag serviceID", + cmd: createCommandRequired(), + want: &fastly.CreateHoneycombInput{ + ServiceID: "123", + ServiceVersion: 4, + Name: fastly.ToPointer("log"), + Token: fastly.ToPointer("tkn"), + Dataset: fastly.ToPointer("logs"), + }, + }, + { + name: "all values set flag serviceID", + cmd: createCommandAll(), + want: &fastly.CreateHoneycombInput{ + ServiceID: "123", + ServiceVersion: 4, + Name: fastly.ToPointer("log"), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + FormatVersion: fastly.ToPointer(2), + Token: fastly.ToPointer("tkn"), + Dataset: fastly.ToPointer("logs"), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + Placement: fastly.ToPointer("none"), + }, + }, + { + name: "error missing serviceID", + cmd: createCommandMissingServiceID(), + want: nil, + wantError: errors.ErrNoServiceID.Error(), + }, + } { + t.Run(testcase.name, func(t *testing.T) { + var bs []byte + out := bytes.NewBuffer(bs) + verboseMode := true + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + AutoCloneFlag: testcase.cmd.AutoClone, + APIClient: testcase.cmd.Globals.APIClient, + Manifest: testcase.cmd.Manifest, + Out: out, + ServiceVersionFlag: testcase.cmd.ServiceVersion, + VerboseMode: verboseMode, + }) + + switch { + case err != nil && testcase.wantError == "": + t.Fatalf("unexpected error getting service details: %v", err) + return + case err != nil && testcase.wantError != "": + testutil.AssertErrorContains(t, err, testcase.wantError) + return + case err == nil && testcase.wantError != "": + t.Fatalf("expected error, have nil (service details: %s, %d)", serviceID, serviceVersion.Number) + case err == nil && testcase.wantError == "": + have, err := testcase.cmd.ConstructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertEqual(t, testcase.want, have) + } + }) + } +} + +func TestUpdateHoneycombInput(t *testing.T) { + scenarios := []struct { + name string + cmd *honeycomb.UpdateCommand + api mock.API + want *fastly.UpdateHoneycombInput + wantError string + }{ + { + name: "no updates", + cmd: updateCommandNoUpdates(), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + GetHoneycombFn: getHoneycombOK, + }, + want: &fastly.UpdateHoneycombInput{ + ServiceID: "123", + ServiceVersion: 4, + Name: "log", + }, + }, + { + name: "all values set flag serviceID", + cmd: updateCommandAll(), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + GetHoneycombFn: getHoneycombOK, + }, + want: &fastly.UpdateHoneycombInput{ + ServiceID: "123", + ServiceVersion: 4, + Name: "log", + NewName: fastly.ToPointer("new1"), + Format: fastly.ToPointer("new2"), + FormatVersion: fastly.ToPointer(3), + Token: fastly.ToPointer("new3"), + Dataset: fastly.ToPointer("new4"), + ResponseCondition: fastly.ToPointer("new5"), + Placement: fastly.ToPointer("new6"), + }, + }, + { + name: "error missing serviceID", + cmd: updateCommandMissingServiceID(), + want: nil, + wantError: errors.ErrNoServiceID.Error(), + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(testcase.name, func(t *testing.T) { + testcase.cmd.Globals.APIClient = testcase.api + + var bs []byte + out := bytes.NewBuffer(bs) + verboseMode := true + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + AutoCloneFlag: testcase.cmd.AutoClone, + APIClient: testcase.api, + Manifest: testcase.cmd.Manifest, + Out: out, + ServiceVersionFlag: testcase.cmd.ServiceVersion, + VerboseMode: verboseMode, + }) + + switch { + case err != nil && testcase.wantError == "": + t.Fatalf("unexpected error getting service details: %v", err) + return + case err != nil && testcase.wantError != "": + testutil.AssertErrorContains(t, err, testcase.wantError) + return + case err == nil && testcase.wantError != "": + t.Fatalf("expected error, have nil (service details: %s, %d)", serviceID, serviceVersion.Number) + case err == nil && testcase.wantError == "": + have, err := testcase.cmd.ConstructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertEqual(t, testcase.want, have) + } + }) + } +} + +func createCommandRequired() *honeycomb.CreateCommand { + var b bytes.Buffer + + g := global.Data{ + Config: config.File{}, + Env: config.Environment{}, + Output: &b, + } + g.APIClient, _ = mock.APIClient(mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + })("token", "endpoint", false) + + return &honeycomb.CreateCommand{ + Base: argparser.Base{ + Globals: &g, + }, + Manifest: manifest.Data{ + Flag: manifest.Flag{ + ServiceID: "123", + }, + }, + EndpointName: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "log"}, + Token: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "tkn"}, + Dataset: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "logs"}, + ServiceVersion: argparser.OptionalServiceVersion{ + OptionalString: argparser.OptionalString{Value: "1"}, + }, + AutoClone: argparser.OptionalAutoClone{ + OptionalBool: argparser.OptionalBool{ + Optional: argparser.Optional{ + WasSet: true, + }, + Value: true, + }, + }, + } +} + +func createCommandAll() *honeycomb.CreateCommand { + var b bytes.Buffer + + g := global.Data{ + Config: config.File{}, + Env: config.Environment{}, + Output: &b, + } + g.APIClient, _ = mock.APIClient(mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + })("token", "endpoint", false) + + return &honeycomb.CreateCommand{ + Base: argparser.Base{ + Globals: &g, + }, + Manifest: manifest.Data{ + Flag: manifest.Flag{ + ServiceID: "123", + }, + }, + EndpointName: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "log"}, + Token: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "tkn"}, + Dataset: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "logs"}, + ServiceVersion: argparser.OptionalServiceVersion{ + OptionalString: argparser.OptionalString{Value: "1"}, + }, + AutoClone: argparser.OptionalAutoClone{ + OptionalBool: argparser.OptionalBool{ + Optional: argparser.Optional{ + WasSet: true, + }, + Value: true, + }, + }, + Format: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: `%h %l %u %t "%r" %>s %b`}, + FormatVersion: argparser.OptionalInt{Optional: argparser.Optional{WasSet: true}, Value: 2}, + ResponseCondition: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "Prevent default logging"}, + Placement: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "none"}, + } +} + +func createCommandMissingServiceID() *honeycomb.CreateCommand { + res := createCommandAll() + res.Manifest = manifest.Data{} + return res +} + +func updateCommandNoUpdates() *honeycomb.UpdateCommand { + var b bytes.Buffer + + g := global.Data{ + Config: config.File{}, + Env: config.Environment{}, + Output: &b, + } + + return &honeycomb.UpdateCommand{ + Base: argparser.Base{ + Globals: &g, + }, + Manifest: manifest.Data{ + Flag: manifest.Flag{ + ServiceID: "123", + }, + }, + EndpointName: "log", + ServiceVersion: argparser.OptionalServiceVersion{ + OptionalString: argparser.OptionalString{Value: "1"}, + }, + AutoClone: argparser.OptionalAutoClone{ + OptionalBool: argparser.OptionalBool{ + Optional: argparser.Optional{ + WasSet: true, + }, + Value: true, + }, + }, + } +} + +func updateCommandAll() *honeycomb.UpdateCommand { + var b bytes.Buffer + + g := global.Data{ + Config: config.File{}, + Env: config.Environment{}, + Output: &b, + } + + return &honeycomb.UpdateCommand{ + Base: argparser.Base{ + Globals: &g, + }, + Manifest: manifest.Data{ + Flag: manifest.Flag{ + ServiceID: "123", + }, + }, + EndpointName: "log", + ServiceVersion: argparser.OptionalServiceVersion{ + OptionalString: argparser.OptionalString{Value: "1"}, + }, + AutoClone: argparser.OptionalAutoClone{ + OptionalBool: argparser.OptionalBool{ + Optional: argparser.Optional{ + WasSet: true, + }, + Value: true, + }, + }, + NewName: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new1"}, + Format: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new2"}, + FormatVersion: argparser.OptionalInt{Optional: argparser.Optional{WasSet: true}, Value: 3}, + Token: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new3"}, + Dataset: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new4"}, + ResponseCondition: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new5"}, + Placement: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new6"}, + } +} + +func updateCommandMissingServiceID() *honeycomb.UpdateCommand { + res := updateCommandAll() + res.Manifest = manifest.Data{} + return res +} diff --git a/pkg/commands/logging/honeycomb/list.go b/pkg/commands/logging/honeycomb/list.go new file mode 100644 index 000000000..2b064361f --- /dev/null +++ b/pkg/commands/logging/honeycomb/list.go @@ -0,0 +1,124 @@ +package honeycomb + +import ( + "fmt" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// ListCommand calls the Fastly API to list Honeycomb logging endpoints. +type ListCommand struct { + argparser.Base + argparser.JSONOutput + + Input fastly.ListHoneycombsInput + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion +} + +// NewListCommand returns a usable command registered under the parent. +func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { + c := ListCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("list", "List Honeycomb endpoints on a Fastly service version") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + return &c +} + +// Exec invokes the application logic for the command. +func (c *ListCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fsterr.ServiceVersion(serviceVersion), + }) + return err + } + + c.Input.ServiceID = serviceID + c.Input.ServiceVersion = fastly.ToValue(serviceVersion.Number) + + o, err := c.Globals.APIClient.ListHoneycombs(&c.Input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + if !c.Globals.Verbose() { + tw := text.NewTable(out) + tw.AddHeader("SERVICE", "VERSION", "NAME") + for _, honeycomb := range o { + tw.AddLine( + fastly.ToValue(honeycomb.ServiceID), + fastly.ToValue(honeycomb.ServiceVersion), + fastly.ToValue(honeycomb.Name), + ) + } + tw.Print() + return nil + } + + fmt.Fprintf(out, "Version: %d\n", c.Input.ServiceVersion) + for i, honeycomb := range o { + fmt.Fprintf(out, "\tHoneycomb %d/%d\n", i+1, len(o)) + fmt.Fprintf(out, "\t\tService ID: %s\n", fastly.ToValue(honeycomb.ServiceID)) + fmt.Fprintf(out, "\t\tVersion: %d\n", fastly.ToValue(honeycomb.ServiceVersion)) + fmt.Fprintf(out, "\t\tName: %s\n", fastly.ToValue(honeycomb.Name)) + fmt.Fprintf(out, "\t\tDataset: %s\n", fastly.ToValue(honeycomb.Dataset)) + fmt.Fprintf(out, "\t\tToken: %s\n", fastly.ToValue(honeycomb.Token)) + fmt.Fprintf(out, "\t\tFormat: %s\n", fastly.ToValue(honeycomb.Format)) + fmt.Fprintf(out, "\t\tFormat version: %d\n", fastly.ToValue(honeycomb.FormatVersion)) + fmt.Fprintf(out, "\t\tResponse condition: %s\n", fastly.ToValue(honeycomb.ResponseCondition)) + fmt.Fprintf(out, "\t\tPlacement: %s\n", fastly.ToValue(honeycomb.Placement)) + } + fmt.Fprintln(out) + + return nil +} diff --git a/pkg/commands/logging/honeycomb/root.go b/pkg/commands/logging/honeycomb/root.go new file mode 100644 index 000000000..8b6eec73e --- /dev/null +++ b/pkg/commands/logging/honeycomb/root.go @@ -0,0 +1,31 @@ +package honeycomb + +import ( + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" +) + +// RootCommand is the parent command for all subcommands in this package. +// It should be installed under the primary root command. +type RootCommand struct { + argparser.Base + // no flags +} + +// CommandName is the string to be used to invoke this command. +const CommandName = "honeycomb" + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent argparser.Registerer, g *global.Data) *RootCommand { + var c RootCommand + c.Globals = g + c.CmdClause = parent.Command(CommandName, "Manipulate Fastly service version Honeycomb logging endpoints") + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { + panic("unreachable") +} diff --git a/pkg/commands/logging/honeycomb/update.go b/pkg/commands/logging/honeycomb/update.go new file mode 100644 index 000000000..0633ddd30 --- /dev/null +++ b/pkg/commands/logging/honeycomb/update.go @@ -0,0 +1,163 @@ +package honeycomb + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/logging/common" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/text" +) + +// UpdateCommand calls the Fastly API to update a Honeycomb logging endpoint. +type UpdateCommand struct { + argparser.Base + Manifest manifest.Data + + // Required. + EndpointName string // Can't shadow argparser.Base method Name(). + ServiceName argparser.OptionalServiceNameID + ServiceVersion argparser.OptionalServiceVersion + + // Optional. + AutoClone argparser.OptionalAutoClone + NewName argparser.OptionalString + Format argparser.OptionalString + FormatVersion argparser.OptionalInt + Dataset argparser.OptionalString + Token argparser.OptionalString + ResponseCondition argparser.OptionalString + Placement argparser.OptionalString +} + +// NewUpdateCommand returns a usable command registered under the parent. +func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { + c := UpdateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("update", "Update a Honeycomb logging endpoint on a Fastly service version") + + // Required. + c.CmdClause.Flag("name", "The name of the Honeycomb logging object").Short('n').Required().StringVar(&c.EndpointName) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.ServiceVersion.Value, + Required: true, + }) + + // Optional. + c.CmdClause.Flag("auth-token", "The Write Key from the Account page of your Honeycomb account").Action(c.Token.Set).StringVar(&c.Token.Value) + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.AutoClone.Set, + Dst: &c.AutoClone.Value, + }) + c.CmdClause.Flag("dataset", "The Honeycomb Dataset you want to log to").Action(c.Dataset.Set).StringVar(&c.Dataset.Value) + common.Format(c.CmdClause, &c.Format) + common.FormatVersion(c.CmdClause, &c.FormatVersion) + c.CmdClause.Flag("new-name", "New name of the Honeycomb logging object").Action(c.NewName.Set).StringVar(&c.NewName.Value) + common.Placement(c.CmdClause, &c.Placement) + common.ResponseCondition(c.CmdClause, &c.ResponseCondition) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.ServiceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.ServiceName.Value, + }) + return &c +} + +// ConstructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *UpdateCommand) ConstructInput(serviceID string, serviceVersion int) (*fastly.UpdateHoneycombInput, error) { + input := fastly.UpdateHoneycombInput{ + ServiceID: serviceID, + ServiceVersion: serviceVersion, + Name: c.EndpointName, + } + + if c.NewName.WasSet { + input.NewName = &c.NewName.Value + } + + if c.Format.WasSet { + input.Format = fastly.ToPointer(argparser.Content(c.Format.Value)) + } + + if c.FormatVersion.WasSet { + input.FormatVersion = &c.FormatVersion.Value + } + + if c.Token.WasSet { + input.Token = &c.Token.Value + } + + if c.Dataset.WasSet { + input.Dataset = &c.Dataset.Value + } + + if c.ResponseCondition.WasSet { + input.ResponseCondition = &c.ResponseCondition.Value + } + + if c.Placement.WasSet { + input.Placement = &c.Placement.Value + } + + return &input, nil +} + +// Exec invokes the application logic for the command. +func (c *UpdateCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.AutoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.ServiceName, + ServiceVersionFlag: c.ServiceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + input, err := c.ConstructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + honeycomb, err := c.Globals.APIClient.UpdateHoneycomb(input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + text.Success(out, + "Updated Honeycomb logging endpoint %s (service %s version %d)", + fastly.ToValue(honeycomb.Name), + fastly.ToValue(honeycomb.ServiceID), + fastly.ToValue(honeycomb.ServiceVersion), + ) + return nil +} diff --git a/pkg/commands/logging/https/create.go b/pkg/commands/logging/https/create.go new file mode 100644 index 000000000..e3e52c63a --- /dev/null +++ b/pkg/commands/logging/https/create.go @@ -0,0 +1,224 @@ +package https + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/logging/common" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/text" +) + +// CreateCommand calls the Fastly API to create an HTTPS logging endpoint. +type CreateCommand struct { + argparser.Base + Manifest manifest.Data + + // Required. + ServiceName argparser.OptionalServiceNameID + ServiceVersion argparser.OptionalServiceVersion + + // Optional. + AutoClone argparser.OptionalAutoClone + ContentType argparser.OptionalString + EndpointName argparser.OptionalString // Can't shadow argparser.Base method Name(). + Format argparser.OptionalString + FormatVersion argparser.OptionalInt + HeaderName argparser.OptionalString + HeaderValue argparser.OptionalString + JSONFormat argparser.OptionalString + MessageType argparser.OptionalString + Method argparser.OptionalString + Placement argparser.OptionalString + RequestMaxBytes argparser.OptionalInt + RequestMaxEntries argparser.OptionalInt + ResponseCondition argparser.OptionalString + TLSCACert argparser.OptionalString + TLSClientCert argparser.OptionalString + TLSClientKey argparser.OptionalString + TLSHostname argparser.OptionalString + URL argparser.OptionalString +} + +// NewCreateCommand returns a usable command registered under the parent. +func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { + c := CreateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("create", "Create an HTTPS logging endpoint on a Fastly service version").Alias("add") + + // Required. + c.CmdClause.Flag("name", "The name of the HTTPS logging object. Used as a primary key for API access").Short('n').Action(c.EndpointName.Set).StringVar(&c.EndpointName.Value) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.ServiceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.AutoClone.Set, + Dst: &c.AutoClone.Value, + }) + c.CmdClause.Flag("content-type", "Content type of the header sent with the request").Action(c.ContentType.Set).StringVar(&c.ContentType.Value) + common.Format(c.CmdClause, &c.Format) + common.FormatVersion(c.CmdClause, &c.FormatVersion) + c.CmdClause.Flag("header-name", "Name of the custom header sent with the request").Action(c.HeaderName.Set).StringVar(&c.HeaderName.Value) + c.CmdClause.Flag("header-value", "Value of the custom header sent with the request").Action(c.HeaderValue.Set).StringVar(&c.HeaderValue.Value) + c.CmdClause.Flag("json-format", "Enforces valid JSON formatting for log entries. Can be disabled 0, array of json (wraps JSON log batches in an array) 1, or newline delimited json (places each JSON log entry onto a new line in a batch) 2").Action(c.JSONFormat.Set).StringVar(&c.JSONFormat.Value) + common.MessageType(c.CmdClause, &c.MessageType) + c.CmdClause.Flag("method", "HTTP method used for request. Can be POST or PUT. Defaults to POST if not specified").Action(c.Method.Set).StringVar(&c.Method.Value) + common.Placement(c.CmdClause, &c.Placement) + c.CmdClause.Flag("request-max-bytes", "Maximum size of log batch, if non-zero. Defaults to 100MB").Action(c.RequestMaxBytes.Set).IntVar(&c.RequestMaxBytes.Value) + c.CmdClause.Flag("request-max-entries", "Maximum number of logs to append to a batch, if non-zero. Defaults to 10k").Action(c.RequestMaxEntries.Set).IntVar(&c.RequestMaxEntries.Value) + common.ResponseCondition(c.CmdClause, &c.ResponseCondition) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.ServiceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.ServiceName.Value, + }) + common.TLSCACert(c.CmdClause, &c.TLSCACert) + common.TLSClientCert(c.CmdClause, &c.TLSClientCert) + common.TLSClientKey(c.CmdClause, &c.TLSClientKey) + common.TLSHostname(c.CmdClause, &c.TLSHostname) + c.CmdClause.Flag("url", "URL that log data will be sent to. Must use the https protocol").Action(c.URL.Set).StringVar(&c.URL.Value) + return &c +} + +// ConstructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *CreateCommand) ConstructInput(serviceID string, serviceVersion int) (*fastly.CreateHTTPSInput, error) { + var input fastly.CreateHTTPSInput + + input.ServiceID = serviceID + if c.EndpointName.WasSet { + input.Name = &c.EndpointName.Value + } + if c.URL.WasSet { + input.URL = &c.URL.Value + } + input.ServiceVersion = serviceVersion + + if c.ContentType.WasSet { + input.ContentType = &c.ContentType.Value + } + + if c.HeaderName.WasSet { + input.HeaderName = &c.HeaderName.Value + } + + if c.HeaderValue.WasSet { + input.HeaderValue = &c.HeaderValue.Value + } + + if c.Method.WasSet { + input.Method = &c.Method.Value + } + + if c.JSONFormat.WasSet { + input.JSONFormat = &c.JSONFormat.Value + } + + if c.RequestMaxEntries.WasSet { + input.RequestMaxEntries = &c.RequestMaxEntries.Value + } + + if c.RequestMaxBytes.WasSet { + input.RequestMaxBytes = &c.RequestMaxBytes.Value + } + + if c.TLSCACert.WasSet { + input.TLSCACert = &c.TLSCACert.Value + } + + if c.TLSClientCert.WasSet { + input.TLSClientCert = &c.TLSClientCert.Value + } + + if c.TLSClientKey.WasSet { + input.TLSClientKey = &c.TLSClientKey.Value + } + + if c.TLSHostname.WasSet { + input.TLSHostname = &c.TLSHostname.Value + } + + if c.Format.WasSet { + input.Format = fastly.ToPointer(argparser.Content(c.Format.Value)) + } + + if c.FormatVersion.WasSet { + input.FormatVersion = &c.FormatVersion.Value + } + + if c.ResponseCondition.WasSet { + input.ResponseCondition = &c.ResponseCondition.Value + } + + if c.Placement.WasSet { + input.Placement = &c.Placement.Value + } + + if c.MessageType.WasSet { + input.MessageType = &c.MessageType.Value + } + + return &input, nil +} + +// Exec invokes the application logic for the command. +func (c *CreateCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.AutoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.ServiceName, + ServiceVersionFlag: c.ServiceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + input, err := c.ConstructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + d, err := c.Globals.APIClient.CreateHTTPS(input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + text.Success(out, + "Created HTTPS logging endpoint %s (service %s version %d)", + fastly.ToValue(d.Name), + fastly.ToValue(d.ServiceID), + fastly.ToValue(d.ServiceVersion), + ) + return nil +} diff --git a/pkg/commands/logging/https/delete.go b/pkg/commands/logging/https/delete.go new file mode 100644 index 000000000..9a97a83b1 --- /dev/null +++ b/pkg/commands/logging/https/delete.go @@ -0,0 +1,94 @@ +package https + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// DeleteCommand calls the Fastly API to delete an HTTPS logging endpoint. +type DeleteCommand struct { + argparser.Base + Input fastly.DeleteHTTPSInput + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion + autoClone argparser.OptionalAutoClone +} + +// NewDeleteCommand returns a usable command registered under the parent. +func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { + c := DeleteCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("delete", "Delete an HTTPS logging endpoint on a Fastly service version").Alias("remove") + + // Required. + c.CmdClause.Flag("name", "The name of the HTTPS logging object").Short('n').Required().StringVar(&c.Input.Name) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.autoClone.Set, + Dst: &c.autoClone.Value, + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + return &c +} + +// Exec invokes the application logic for the command. +func (c *DeleteCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.autoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + c.Input.ServiceID = serviceID + c.Input.ServiceVersion = fastly.ToValue(serviceVersion.Number) + + if err := c.Globals.APIClient.DeleteHTTPS(&c.Input); err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + text.Success(out, "Deleted HTTPS logging endpoint %s (service %s version %d)", c.Input.Name, c.Input.ServiceID, c.Input.ServiceVersion) + return nil +} diff --git a/pkg/commands/logging/https/describe.go b/pkg/commands/logging/https/describe.go new file mode 100644 index 000000000..dac8e3fe0 --- /dev/null +++ b/pkg/commands/logging/https/describe.go @@ -0,0 +1,121 @@ +package https + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// DescribeCommand calls the Fastly API to describe an HTTPS logging endpoint. +type DescribeCommand struct { + argparser.Base + argparser.JSONOutput + + Input fastly.GetHTTPSInput + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion +} + +// NewDescribeCommand returns a usable command registered under the parent. +func NewDescribeCommand(parent argparser.Registerer, g *global.Data) *DescribeCommand { + c := DescribeCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("describe", "Show detailed information about an HTTPS logging endpoint on a Fastly service version").Alias("get") + + // Required. + c.CmdClause.Flag("name", "The name of the HTTPS logging object").Short('n').Required().StringVar(&c.Input.Name) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + return &c +} + +// Exec invokes the application logic for the command. +func (c *DescribeCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fsterr.ServiceVersion(serviceVersion), + }) + return err + } + + c.Input.ServiceID = serviceID + c.Input.ServiceVersion = fastly.ToValue(serviceVersion.Number) + + o, err := c.Globals.APIClient.GetHTTPS(&c.Input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + lines := text.Lines{ + "Content type": fastly.ToValue(o.ContentType), + "Format version": fastly.ToValue(o.FormatVersion), + "Format": fastly.ToValue(o.Format), + "Header name": fastly.ToValue(o.HeaderName), + "Header value": fastly.ToValue(o.HeaderValue), + "JSON format": fastly.ToValue(o.JSONFormat), + "Message type": fastly.ToValue(o.MessageType), + "Method": fastly.ToValue(o.Method), + "Name": fastly.ToValue(o.Name), + "Placement": fastly.ToValue(o.Placement), + "Request max bytes": fastly.ToValue(o.RequestMaxBytes), + "Request max entries": fastly.ToValue(o.RequestMaxEntries), + "Response condition": fastly.ToValue(o.ResponseCondition), + "TLS CA certificate": fastly.ToValue(o.TLSCACert), + "TLS client certificate": fastly.ToValue(o.TLSClientCert), + "TLS client key": fastly.ToValue(o.TLSClientKey), + "TLS hostname": fastly.ToValue(o.TLSHostname), + "URL": fastly.ToValue(o.URL), + "Version": fastly.ToValue(o.ServiceVersion), + } + if !c.Globals.Verbose() { + lines["Service ID"] = fastly.ToValue(o.ServiceID) + } + text.PrintLines(out, lines) + + return nil +} diff --git a/pkg/logging/https/doc.go b/pkg/commands/logging/https/doc.go similarity index 100% rename from pkg/logging/https/doc.go rename to pkg/commands/logging/https/doc.go diff --git a/pkg/commands/logging/https/https_integration_test.go b/pkg/commands/logging/https/https_integration_test.go new file mode 100644 index 000000000..3117027d3 --- /dev/null +++ b/pkg/commands/logging/https/https_integration_test.go @@ -0,0 +1,502 @@ +package https_test + +import ( + "bytes" + "errors" + "io" + "net/http" + "strings" + "testing" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/app" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" +) + +func TestHTTPSCreate(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging https create --service-id 123 --version 1 --name log --url example.com --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + CreateHTTPSFn: createHTTPSOK, + }, + wantOutput: "Created HTTPS logging endpoint log (service 123 version 4)", + }, + { + args: args("logging https create --service-id 123 --version 1 --name log --url example.com --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + CreateHTTPSFn: createHTTPSError, + }, + wantError: errTest.Error(), + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertStringContains(t, stdout.String(), testcase.wantOutput) + }) + } +} + +func TestHTTPSList(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging https list --service-id 123 --version 1"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListHTTPSFn: listHTTPSsOK, + }, + wantOutput: listHTTPSsShortOutput, + }, + { + args: args("logging https list --service-id 123 --version 1 --verbose"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListHTTPSFn: listHTTPSsOK, + }, + wantOutput: listHTTPSsVerboseOutput, + }, + { + args: args("logging https list --service-id 123 --version 1 -v"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListHTTPSFn: listHTTPSsOK, + }, + wantOutput: listHTTPSsVerboseOutput, + }, + { + args: args("logging https --verbose list --service-id 123 --version 1"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListHTTPSFn: listHTTPSsOK, + }, + wantOutput: listHTTPSsVerboseOutput, + }, + { + args: args("logging -v https list --service-id 123 --version 1"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListHTTPSFn: listHTTPSsOK, + }, + wantOutput: listHTTPSsVerboseOutput, + }, + { + args: args("logging https list --service-id 123 --version 1"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListHTTPSFn: listHTTPSsError, + }, + wantError: errTest.Error(), + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertString(t, testcase.wantOutput, stdout.String()) + }) + } +} + +func TestHTTPSDescribe(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging https describe --service-id 123 --version 1"), + wantError: "error parsing arguments: required flag --name not provided", + }, + { + args: args("logging https describe --service-id 123 --version 1 --name logs"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + GetHTTPSFn: getHTTPSError, + }, + wantError: errTest.Error(), + }, + { + args: args("logging https describe --service-id 123 --version 1 --name logs"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + GetHTTPSFn: getHTTPSOK, + }, + wantOutput: describeHTTPSOutput, + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertString(t, testcase.wantOutput, stdout.String()) + }) + } +} + +func TestHTTPSUpdate(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging https update --service-id 123 --version 1 --new-name log"), + wantError: "error parsing arguments: required flag --name not provided", + }, + { + args: args("logging https update --service-id 123 --version 1 --name logs --new-name log --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + UpdateHTTPSFn: updateHTTPSError, + }, + wantError: errTest.Error(), + }, + { + args: args("logging https update --service-id 123 --version 1 --name logs --new-name log --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + UpdateHTTPSFn: updateHTTPSOK, + }, + wantOutput: "Updated HTTPS logging endpoint log (service 123 version 4)", + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertStringContains(t, stdout.String(), testcase.wantOutput) + }) + } +} + +func TestHTTPSDelete(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging https delete --service-id 123 --version 1"), + wantError: "error parsing arguments: required flag --name not provided", + }, + { + args: args("logging https delete --service-id 123 --version 1 --name logs --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + DeleteHTTPSFn: deleteHTTPSError, + }, + wantError: errTest.Error(), + }, + { + args: args("logging https delete --service-id 123 --version 1 --name logs --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + DeleteHTTPSFn: deleteHTTPSOK, + }, + wantOutput: "Deleted HTTPS logging endpoint logs (service 123 version 4)", + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertStringContains(t, stdout.String(), testcase.wantOutput) + }) + } +} + +var errTest = errors.New("fixture error") + +func createHTTPSOK(i *fastly.CreateHTTPSInput) (*fastly.HTTPS, error) { + return &fastly.HTTPS{ + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("log"), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + URL: fastly.ToPointer("example.com"), + RequestMaxEntries: fastly.ToPointer(2), + RequestMaxBytes: fastly.ToPointer(2), + ContentType: fastly.ToPointer("application/json"), + HeaderName: fastly.ToPointer("name"), + HeaderValue: fastly.ToPointer("value"), + Method: fastly.ToPointer(http.MethodGet), + JSONFormat: fastly.ToPointer("1"), + Placement: fastly.ToPointer("none"), + TLSCACert: fastly.ToPointer("-----BEGIN CERTIFICATE-----foo"), + TLSClientCert: fastly.ToPointer("-----BEGIN CERTIFICATE-----bar"), + TLSClientKey: fastly.ToPointer("-----BEGIN PRIVATE KEY-----bar"), + TLSHostname: fastly.ToPointer("example.com"), + MessageType: fastly.ToPointer("classic"), + FormatVersion: fastly.ToPointer(2), + }, nil +} + +func createHTTPSError(_ *fastly.CreateHTTPSInput) (*fastly.HTTPS, error) { + return nil, errTest +} + +func listHTTPSsOK(i *fastly.ListHTTPSInput) ([]*fastly.HTTPS, error) { + return []*fastly.HTTPS{ + { + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("logs"), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + URL: fastly.ToPointer("example.com"), + RequestMaxEntries: fastly.ToPointer(2), + RequestMaxBytes: fastly.ToPointer(2), + ContentType: fastly.ToPointer("application/json"), + HeaderName: fastly.ToPointer("name"), + HeaderValue: fastly.ToPointer("value"), + Method: fastly.ToPointer(http.MethodGet), + JSONFormat: fastly.ToPointer("1"), + Placement: fastly.ToPointer("none"), + TLSCACert: fastly.ToPointer("-----BEGIN CERTIFICATE-----foo"), + TLSClientCert: fastly.ToPointer("-----BEGIN CERTIFICATE-----bar"), + TLSClientKey: fastly.ToPointer("-----BEGIN PRIVATE KEY-----bar"), + TLSHostname: fastly.ToPointer("example.com"), + MessageType: fastly.ToPointer("classic"), + FormatVersion: fastly.ToPointer(2), + }, + { + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("analytics"), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + URL: fastly.ToPointer("analytics.example.com"), + RequestMaxEntries: fastly.ToPointer(2), + RequestMaxBytes: fastly.ToPointer(2), + ContentType: fastly.ToPointer("application/json"), + HeaderName: fastly.ToPointer("name"), + HeaderValue: fastly.ToPointer("value"), + Method: fastly.ToPointer(http.MethodGet), + JSONFormat: fastly.ToPointer("1"), + Placement: fastly.ToPointer("none"), + TLSCACert: fastly.ToPointer("-----BEGIN CERTIFICATE-----foo"), + TLSClientCert: fastly.ToPointer("-----BEGIN CERTIFICATE-----bar"), + TLSClientKey: fastly.ToPointer("-----BEGIN PRIVATE KEY-----bar"), + TLSHostname: fastly.ToPointer("example.com"), + MessageType: fastly.ToPointer("classic"), + FormatVersion: fastly.ToPointer(2), + }, + }, nil +} + +func listHTTPSsError(_ *fastly.ListHTTPSInput) ([]*fastly.HTTPS, error) { + return nil, errTest +} + +var listHTTPSsShortOutput = strings.TrimSpace(` +SERVICE VERSION NAME +123 1 logs +123 1 analytics +`) + "\n" + +var listHTTPSsVerboseOutput = strings.TrimSpace(` +Fastly API endpoint: https://api.fastly.com +Fastly API token provided via config file (profile: user) + +Service ID (via --service-id): 123 + +Version: 1 + HTTPS 1/2 + Service ID: 123 + Version: 1 + Name: logs + URL: example.com + Content type: application/json + Header name: name + Header value: value + Method: GET + JSON format: 1 + TLS CA certificate: -----BEGIN CERTIFICATE-----foo + TLS client certificate: -----BEGIN CERTIFICATE-----bar + TLS client key: -----BEGIN PRIVATE KEY-----bar + TLS hostname: example.com + Request max entries: 2 + Request max bytes: 2 + Message type: classic + Format: %h %l %u %t "%r" %>s %b + Format version: 2 + Response condition: Prevent default logging + Placement: none + HTTPS 2/2 + Service ID: 123 + Version: 1 + Name: analytics + URL: analytics.example.com + Content type: application/json + Header name: name + Header value: value + Method: GET + JSON format: 1 + TLS CA certificate: -----BEGIN CERTIFICATE-----foo + TLS client certificate: -----BEGIN CERTIFICATE-----bar + TLS client key: -----BEGIN PRIVATE KEY-----bar + TLS hostname: example.com + Request max entries: 2 + Request max bytes: 2 + Message type: classic + Format: %h %l %u %t "%r" %>s %b + Format version: 2 + Response condition: Prevent default logging + Placement: none +`) + "\n\n" + +func getHTTPSOK(i *fastly.GetHTTPSInput) (*fastly.HTTPS, error) { + return &fastly.HTTPS{ + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("log"), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + URL: fastly.ToPointer("example.com"), + RequestMaxEntries: fastly.ToPointer(2), + RequestMaxBytes: fastly.ToPointer(2), + ContentType: fastly.ToPointer("application/json"), + HeaderName: fastly.ToPointer("name"), + HeaderValue: fastly.ToPointer("value"), + Method: fastly.ToPointer(http.MethodGet), + JSONFormat: fastly.ToPointer("1"), + Placement: fastly.ToPointer("none"), + TLSCACert: fastly.ToPointer("-----BEGIN CERTIFICATE-----foo"), + TLSClientCert: fastly.ToPointer("-----BEGIN CERTIFICATE-----bar"), + TLSClientKey: fastly.ToPointer("-----BEGIN PRIVATE KEY-----bar"), + TLSHostname: fastly.ToPointer("example.com"), + MessageType: fastly.ToPointer("classic"), + FormatVersion: fastly.ToPointer(2), + }, nil +} + +func getHTTPSError(_ *fastly.GetHTTPSInput) (*fastly.HTTPS, error) { + return nil, errTest +} + +var describeHTTPSOutput = "\n" + strings.TrimSpace(` +Content type: application/json +Format: %h %l %u %t "%r" %>s %b +Format version: 2 +Header name: name +Header value: value +JSON format: 1 +Message type: classic +Method: GET +Name: log +Placement: none +Request max bytes: 2 +Request max entries: 2 +Response condition: Prevent default logging +Service ID: 123 +TLS CA certificate: -----BEGIN CERTIFICATE-----foo +TLS client certificate: -----BEGIN CERTIFICATE-----bar +TLS client key: -----BEGIN PRIVATE KEY-----bar +TLS hostname: example.com +URL: example.com +Version: 1 +`) + "\n" + +func updateHTTPSOK(i *fastly.UpdateHTTPSInput) (*fastly.HTTPS, error) { + return &fastly.HTTPS{ + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("log"), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + URL: fastly.ToPointer("example.com"), + RequestMaxEntries: fastly.ToPointer(2), + RequestMaxBytes: fastly.ToPointer(2), + ContentType: fastly.ToPointer("application/json"), + HeaderName: fastly.ToPointer("name"), + HeaderValue: fastly.ToPointer("value"), + Method: fastly.ToPointer(http.MethodGet), + JSONFormat: fastly.ToPointer("1"), + Placement: fastly.ToPointer("none"), + TLSCACert: fastly.ToPointer("-----BEGIN CERTIFICATE-----foo"), + TLSClientCert: fastly.ToPointer("-----BEGIN CERTIFICATE-----bar"), + TLSClientKey: fastly.ToPointer("-----BEGIN PRIVATE KEY-----bar"), + TLSHostname: fastly.ToPointer("example.com"), + MessageType: fastly.ToPointer("classic"), + FormatVersion: fastly.ToPointer(2), + }, nil +} + +func updateHTTPSError(_ *fastly.UpdateHTTPSInput) (*fastly.HTTPS, error) { + return nil, errTest +} + +func deleteHTTPSOK(_ *fastly.DeleteHTTPSInput) error { + return nil +} + +func deleteHTTPSError(_ *fastly.DeleteHTTPSInput) error { + return errTest +} diff --git a/pkg/commands/logging/https/https_test.go b/pkg/commands/logging/https/https_test.go new file mode 100644 index 000000000..886f8b36a --- /dev/null +++ b/pkg/commands/logging/https/https_test.go @@ -0,0 +1,385 @@ +package https_test + +import ( + "bytes" + "net/http" + "testing" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/logging/https" + "github.com/fastly/cli/pkg/config" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" +) + +func TestCreateHTTPSInput(t *testing.T) { + for _, testcase := range []struct { + name string + cmd *https.CreateCommand + want *fastly.CreateHTTPSInput + wantError string + }{ + { + name: "required values set flag serviceID", + cmd: createCommandRequired(), + want: &fastly.CreateHTTPSInput{ + ServiceID: "123", + ServiceVersion: 4, + Name: fastly.ToPointer("log"), + URL: fastly.ToPointer("example.com"), + }, + }, + { + name: "all values set flag serviceID", + cmd: createCommandAll(), + want: &fastly.CreateHTTPSInput{ + ServiceID: "123", + ServiceVersion: 4, + Name: fastly.ToPointer("logs"), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + URL: fastly.ToPointer("example.com"), + RequestMaxEntries: fastly.ToPointer(2), + RequestMaxBytes: fastly.ToPointer(2), + ContentType: fastly.ToPointer("application/json"), + HeaderName: fastly.ToPointer("name"), + HeaderValue: fastly.ToPointer("value"), + Method: fastly.ToPointer(http.MethodGet), + JSONFormat: fastly.ToPointer("1"), + Placement: fastly.ToPointer("none"), + TLSCACert: fastly.ToPointer("-----BEGIN CERTIFICATE-----foo"), + TLSClientCert: fastly.ToPointer("-----BEGIN CERTIFICATE-----bar"), + TLSClientKey: fastly.ToPointer("-----BEGIN PRIVATE KEY-----bar"), + TLSHostname: fastly.ToPointer("example.com"), + MessageType: fastly.ToPointer("classic"), + FormatVersion: fastly.ToPointer(2), + }, + }, + { + name: "error missing serviceID", + cmd: createCommandMissingServiceID(), + want: nil, + wantError: errors.ErrNoServiceID.Error(), + }, + } { + t.Run(testcase.name, func(t *testing.T) { + var bs []byte + out := bytes.NewBuffer(bs) + verboseMode := true + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + AutoCloneFlag: testcase.cmd.AutoClone, + APIClient: testcase.cmd.Globals.APIClient, + Manifest: testcase.cmd.Manifest, + Out: out, + ServiceVersionFlag: testcase.cmd.ServiceVersion, + VerboseMode: verboseMode, + }) + + switch { + case err != nil && testcase.wantError == "": + t.Fatalf("unexpected error getting service details: %v", err) + return + case err != nil && testcase.wantError != "": + testutil.AssertErrorContains(t, err, testcase.wantError) + return + case err == nil && testcase.wantError != "": + t.Fatalf("expected error, have nil (service details: %s, %d)", serviceID, serviceVersion.Number) + case err == nil && testcase.wantError == "": + have, err := testcase.cmd.ConstructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertEqual(t, testcase.want, have) + } + }) + } +} + +func TestUpdateHTTPSInput(t *testing.T) { + scenarios := []struct { + name string + cmd *https.UpdateCommand + api mock.API + want *fastly.UpdateHTTPSInput + wantError string + }{ + { + name: "all values set flag serviceID", + cmd: updateCommandAll(), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + GetHTTPSFn: getHTTPSOK, + }, + want: &fastly.UpdateHTTPSInput{ + ServiceID: "123", + ServiceVersion: 4, + Name: "log", + NewName: fastly.ToPointer("new1"), + ResponseCondition: fastly.ToPointer("new2"), + Format: fastly.ToPointer("new3"), + URL: fastly.ToPointer("new4"), + RequestMaxEntries: fastly.ToPointer(3), + RequestMaxBytes: fastly.ToPointer(3), + ContentType: fastly.ToPointer("new5"), + HeaderName: fastly.ToPointer("new6"), + HeaderValue: fastly.ToPointer("new7"), + Method: fastly.ToPointer("new8"), + JSONFormat: fastly.ToPointer("new9"), + Placement: fastly.ToPointer("new10"), + TLSCACert: fastly.ToPointer("new11"), + TLSClientCert: fastly.ToPointer("new12"), + TLSClientKey: fastly.ToPointer("new13"), + TLSHostname: fastly.ToPointer("new14"), + MessageType: fastly.ToPointer("new15"), + FormatVersion: fastly.ToPointer(3), + }, + }, + { + name: "no updates", + cmd: updateCommandNoUpdates(), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + GetHTTPSFn: getHTTPSOK, + }, + want: &fastly.UpdateHTTPSInput{ + ServiceID: "123", + ServiceVersion: 4, + Name: "log", + }, + }, + { + name: "error missing serviceID", + cmd: updateCommandMissingServiceID(), + want: nil, + wantError: errors.ErrNoServiceID.Error(), + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(testcase.name, func(t *testing.T) { + testcase.cmd.Globals.APIClient = testcase.api + + var bs []byte + out := bytes.NewBuffer(bs) + verboseMode := true + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + AutoCloneFlag: testcase.cmd.AutoClone, + APIClient: testcase.api, + Manifest: testcase.cmd.Manifest, + Out: out, + ServiceVersionFlag: testcase.cmd.ServiceVersion, + VerboseMode: verboseMode, + }) + + switch { + case err != nil && testcase.wantError == "": + t.Fatalf("unexpected error getting service details: %v", err) + return + case err != nil && testcase.wantError != "": + testutil.AssertErrorContains(t, err, testcase.wantError) + return + case err == nil && testcase.wantError != "": + t.Fatalf("expected error, have nil (service details: %s, %d)", serviceID, serviceVersion.Number) + case err == nil && testcase.wantError == "": + have, err := testcase.cmd.ConstructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertEqual(t, testcase.want, have) + } + }) + } +} + +func createCommandRequired() *https.CreateCommand { + var b bytes.Buffer + + g := global.Data{ + Config: config.File{}, + Env: config.Environment{}, + Output: &b, + } + g.APIClient, _ = mock.APIClient(mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + })("token", "endpoint", false) + + return &https.CreateCommand{ + Base: argparser.Base{ + Globals: &g, + }, + Manifest: manifest.Data{ + Flag: manifest.Flag{ + ServiceID: "123", + }, + }, + ServiceVersion: argparser.OptionalServiceVersion{ + OptionalString: argparser.OptionalString{Value: "1"}, + }, + AutoClone: argparser.OptionalAutoClone{ + OptionalBool: argparser.OptionalBool{ + Optional: argparser.Optional{ + WasSet: true, + }, + Value: true, + }, + }, + EndpointName: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "log"}, + URL: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "example.com"}, + } +} + +func createCommandAll() *https.CreateCommand { + var b bytes.Buffer + + g := global.Data{ + Config: config.File{}, + Env: config.Environment{}, + Output: &b, + } + g.APIClient, _ = mock.APIClient(mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + })("token", "endpoint", false) + + return &https.CreateCommand{ + Base: argparser.Base{ + Globals: &g, + }, + Manifest: manifest.Data{ + Flag: manifest.Flag{ + ServiceID: "123", + }, + }, + ServiceVersion: argparser.OptionalServiceVersion{ + OptionalString: argparser.OptionalString{Value: "1"}, + }, + AutoClone: argparser.OptionalAutoClone{ + OptionalBool: argparser.OptionalBool{ + Optional: argparser.Optional{ + WasSet: true, + }, + Value: true, + }, + }, + EndpointName: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "logs"}, + URL: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "example.com"}, + ContentType: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "application/json"}, + HeaderName: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "name"}, + HeaderValue: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "value"}, + Method: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: http.MethodGet}, + JSONFormat: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "1"}, + MessageType: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "classic"}, + RequestMaxEntries: argparser.OptionalInt{Optional: argparser.Optional{WasSet: true}, Value: 2}, + RequestMaxBytes: argparser.OptionalInt{Optional: argparser.Optional{WasSet: true}, Value: 2}, + Format: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: `%h %l %u %t "%r" %>s %b`}, + FormatVersion: argparser.OptionalInt{Optional: argparser.Optional{WasSet: true}, Value: 2}, + ResponseCondition: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "Prevent default logging"}, + Placement: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "none"}, + TLSCACert: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "-----BEGIN CERTIFICATE-----foo"}, + TLSHostname: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "example.com"}, + TLSClientCert: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "-----BEGIN CERTIFICATE-----bar"}, + TLSClientKey: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "-----BEGIN PRIVATE KEY-----bar"}, + } +} + +func createCommandMissingServiceID() *https.CreateCommand { + res := createCommandAll() + res.Manifest = manifest.Data{} + return res +} + +func updateCommandNoUpdates() *https.UpdateCommand { + var b bytes.Buffer + + g := global.Data{ + Config: config.File{}, + Env: config.Environment{}, + Output: &b, + } + + return &https.UpdateCommand{ + Base: argparser.Base{ + Globals: &g, + }, + Manifest: manifest.Data{ + Flag: manifest.Flag{ + ServiceID: "123", + }, + }, + EndpointName: "log", + ServiceVersion: argparser.OptionalServiceVersion{ + OptionalString: argparser.OptionalString{Value: "1"}, + }, + AutoClone: argparser.OptionalAutoClone{ + OptionalBool: argparser.OptionalBool{ + Optional: argparser.Optional{ + WasSet: true, + }, + Value: true, + }, + }, + } +} + +func updateCommandAll() *https.UpdateCommand { + var b bytes.Buffer + + g := global.Data{ + Config: config.File{}, + Env: config.Environment{}, + Output: &b, + } + + return &https.UpdateCommand{ + Base: argparser.Base{ + Globals: &g, + }, + Manifest: manifest.Data{ + Flag: manifest.Flag{ + ServiceID: "123", + }, + }, + EndpointName: "log", + ServiceVersion: argparser.OptionalServiceVersion{ + OptionalString: argparser.OptionalString{Value: "1"}, + }, + AutoClone: argparser.OptionalAutoClone{ + OptionalBool: argparser.OptionalBool{ + Optional: argparser.Optional{ + WasSet: true, + }, + Value: true, + }, + }, + NewName: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new1"}, + ResponseCondition: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new2"}, + Format: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new3"}, + URL: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new4"}, + ContentType: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new5"}, + HeaderName: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new6"}, + HeaderValue: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new7"}, + Method: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new8"}, + JSONFormat: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new9"}, + Placement: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new10"}, + RequestMaxEntries: argparser.OptionalInt{Optional: argparser.Optional{WasSet: true}, Value: 3}, + RequestMaxBytes: argparser.OptionalInt{Optional: argparser.Optional{WasSet: true}, Value: 3}, + TLSCACert: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new11"}, + TLSClientCert: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new12"}, + TLSClientKey: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new13"}, + TLSHostname: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new14"}, + MessageType: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new15"}, + FormatVersion: argparser.OptionalInt{Optional: argparser.Optional{WasSet: true}, Value: 3}, + } +} + +func updateCommandMissingServiceID() *https.UpdateCommand { + res := updateCommandAll() + res.Manifest = manifest.Data{} + return res +} diff --git a/pkg/commands/logging/https/list.go b/pkg/commands/logging/https/list.go new file mode 100644 index 000000000..48c2ec733 --- /dev/null +++ b/pkg/commands/logging/https/list.go @@ -0,0 +1,135 @@ +package https + +import ( + "fmt" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// ListCommand calls the Fastly API to list HTTPS logging endpoints. +type ListCommand struct { + argparser.Base + argparser.JSONOutput + + Input fastly.ListHTTPSInput + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion +} + +// NewListCommand returns a usable command registered under the parent. +func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { + c := ListCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("list", "List HTTPS endpoints on a Fastly service version") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + return &c +} + +// Exec invokes the application logic for the command. +func (c *ListCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fsterr.ServiceVersion(serviceVersion), + }) + return err + } + + c.Input.ServiceID = serviceID + c.Input.ServiceVersion = fastly.ToValue(serviceVersion.Number) + + o, err := c.Globals.APIClient.ListHTTPS(&c.Input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + if !c.Globals.Verbose() { + tw := text.NewTable(out) + tw.AddHeader("SERVICE", "VERSION", "NAME") + for _, https := range o { + tw.AddLine( + fastly.ToValue(https.ServiceID), + fastly.ToValue(https.ServiceVersion), + fastly.ToValue(https.Name), + ) + } + tw.Print() + return nil + } + + fmt.Fprintf(out, "Version: %d\n", c.Input.ServiceVersion) + for i, https := range o { + fmt.Fprintf(out, "\tHTTPS %d/%d\n", i+1, len(o)) + fmt.Fprintf(out, "\t\tService ID: %s\n", fastly.ToValue(https.ServiceID)) + fmt.Fprintf(out, "\t\tVersion: %d\n", fastly.ToValue(https.ServiceVersion)) + fmt.Fprintf(out, "\t\tName: %s\n", fastly.ToValue(https.Name)) + fmt.Fprintf(out, "\t\tURL: %s\n", fastly.ToValue(https.URL)) + fmt.Fprintf(out, "\t\tContent type: %s\n", fastly.ToValue(https.ContentType)) + fmt.Fprintf(out, "\t\tHeader name: %s\n", fastly.ToValue(https.HeaderName)) + fmt.Fprintf(out, "\t\tHeader value: %s\n", fastly.ToValue(https.HeaderValue)) + fmt.Fprintf(out, "\t\tMethod: %s\n", fastly.ToValue(https.Method)) + fmt.Fprintf(out, "\t\tJSON format: %s\n", fastly.ToValue(https.JSONFormat)) + fmt.Fprintf(out, "\t\tTLS CA certificate: %s\n", fastly.ToValue(https.TLSCACert)) + fmt.Fprintf(out, "\t\tTLS client certificate: %s\n", fastly.ToValue(https.TLSClientCert)) + fmt.Fprintf(out, "\t\tTLS client key: %s\n", fastly.ToValue(https.TLSClientKey)) + fmt.Fprintf(out, "\t\tTLS hostname: %s\n", fastly.ToValue(https.TLSHostname)) + fmt.Fprintf(out, "\t\tRequest max entries: %d\n", fastly.ToValue(https.RequestMaxEntries)) + fmt.Fprintf(out, "\t\tRequest max bytes: %d\n", fastly.ToValue(https.RequestMaxBytes)) + fmt.Fprintf(out, "\t\tMessage type: %s\n", fastly.ToValue(https.MessageType)) + fmt.Fprintf(out, "\t\tFormat: %s\n", fastly.ToValue(https.Format)) + fmt.Fprintf(out, "\t\tFormat version: %d\n", fastly.ToValue(https.FormatVersion)) + fmt.Fprintf(out, "\t\tResponse condition: %s\n", fastly.ToValue(https.ResponseCondition)) + fmt.Fprintf(out, "\t\tPlacement: %s\n", fastly.ToValue(https.Placement)) + } + fmt.Fprintln(out) + + return nil +} diff --git a/pkg/commands/logging/https/root.go b/pkg/commands/logging/https/root.go new file mode 100644 index 000000000..2e250b5ac --- /dev/null +++ b/pkg/commands/logging/https/root.go @@ -0,0 +1,31 @@ +package https + +import ( + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" +) + +// RootCommand is the parent command for all subcommands in this package. +// It should be installed under the primary root command. +type RootCommand struct { + argparser.Base + // no flags +} + +// CommandName is the string to be used to invoke this command. +const CommandName = "https" + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent argparser.Registerer, g *global.Data) *RootCommand { + var c RootCommand + c.Globals = g + c.CmdClause = parent.Command(CommandName, "Manipulate Fastly service version HTTPS logging endpoints") + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { + panic("unreachable") +} diff --git a/pkg/commands/logging/https/update.go b/pkg/commands/logging/https/update.go new file mode 100644 index 000000000..d13d90c1a --- /dev/null +++ b/pkg/commands/logging/https/update.go @@ -0,0 +1,229 @@ +package https + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/logging/common" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/text" +) + +// UpdateCommand calls the Fastly API to update an HTTPS logging endpoint. +type UpdateCommand struct { + argparser.Base + Manifest manifest.Data + + // Required. + EndpointName string // Can't shadow argparser.Base method Name(). + ServiceName argparser.OptionalServiceNameID + ServiceVersion argparser.OptionalServiceVersion + + // Optional. + AutoClone argparser.OptionalAutoClone + NewName argparser.OptionalString + URL argparser.OptionalString + RequestMaxEntries argparser.OptionalInt + RequestMaxBytes argparser.OptionalInt + TLSCACert argparser.OptionalString + TLSClientCert argparser.OptionalString + TLSClientKey argparser.OptionalString + TLSHostname argparser.OptionalString + MessageType argparser.OptionalString + ContentType argparser.OptionalString + HeaderName argparser.OptionalString + HeaderValue argparser.OptionalString + Method argparser.OptionalString + JSONFormat argparser.OptionalString + Format argparser.OptionalString + FormatVersion argparser.OptionalInt + Placement argparser.OptionalString + ResponseCondition argparser.OptionalString +} + +// NewUpdateCommand returns a usable command registered under the parent. +func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { + c := UpdateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("update", "Update an HTTPS logging endpoint on a Fastly service version") + + // Required. + c.CmdClause.Flag("name", "The name of the HTTPS logging object").Short('n').Required().StringVar(&c.EndpointName) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.ServiceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.AutoClone.Set, + Dst: &c.AutoClone.Value, + }) + c.CmdClause.Flag("content-type", "Content type of the header sent with the request").Action(c.ContentType.Set).StringVar(&c.ContentType.Value) + common.Format(c.CmdClause, &c.Format) + common.FormatVersion(c.CmdClause, &c.FormatVersion) + c.CmdClause.Flag("header-name", "Name of the custom header sent with the request").Action(c.HeaderName.Set).StringVar(&c.HeaderName.Value) + c.CmdClause.Flag("header-value", "Value of the custom header sent with the request").Action(c.HeaderValue.Set).StringVar(&c.HeaderValue.Value) + c.CmdClause.Flag("json-format", "Enforces valid JSON formatting for log entries. Can be disabled 0, array of json (wraps JSON log batches in an array) 1, or newline delimited json (places each JSON log entry onto a new line in a batch) 2").Action(c.JSONFormat.Set).StringVar(&c.JSONFormat.Value) + common.MessageType(c.CmdClause, &c.MessageType) + c.CmdClause.Flag("method", "HTTP method used for request. Can be POST or PUT. Defaults to POST if not specified").Action(c.Method.Set).StringVar(&c.Method.Value) + c.CmdClause.Flag("new-name", "New name of the HTTPS logging object").Action(c.NewName.Set).StringVar(&c.NewName.Value) + common.Placement(c.CmdClause, &c.Placement) + c.CmdClause.Flag("request-max-bytes", "Maximum size of log batch, if non-zero. Defaults to 100MB").Action(c.RequestMaxBytes.Set).IntVar(&c.RequestMaxBytes.Value) + c.CmdClause.Flag("request-max-entries", "Maximum number of logs to append to a batch, if non-zero. Defaults to 10k").Action(c.RequestMaxEntries.Set).IntVar(&c.RequestMaxEntries.Value) + common.ResponseCondition(c.CmdClause, &c.ResponseCondition) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.ServiceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.ServiceName.Value, + }) + common.TLSCACert(c.CmdClause, &c.TLSCACert) + common.TLSClientCert(c.CmdClause, &c.TLSClientCert) + common.TLSClientKey(c.CmdClause, &c.TLSClientKey) + common.TLSHostname(c.CmdClause, &c.TLSHostname) + c.CmdClause.Flag("url", "URL that log data will be sent to. Must use the https protocol").Action(c.URL.Set).StringVar(&c.URL.Value) + return &c +} + +// ConstructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *UpdateCommand) ConstructInput(serviceID string, serviceVersion int) (*fastly.UpdateHTTPSInput, error) { + input := fastly.UpdateHTTPSInput{ + ServiceID: serviceID, + ServiceVersion: serviceVersion, + Name: c.EndpointName, + } + + if c.NewName.WasSet { + input.NewName = &c.NewName.Value + } + + if c.URL.WasSet { + input.URL = &c.URL.Value + } + + if c.ContentType.WasSet { + input.ContentType = &c.ContentType.Value + } + + if c.JSONFormat.WasSet { + input.JSONFormat = &c.JSONFormat.Value + } + + if c.HeaderName.WasSet { + input.HeaderName = &c.HeaderName.Value + } + + if c.HeaderValue.WasSet { + input.HeaderValue = &c.HeaderValue.Value + } + + if c.Method.WasSet { + input.Method = &c.Method.Value + } + + if c.RequestMaxEntries.WasSet { + input.RequestMaxEntries = &c.RequestMaxEntries.Value + } + + if c.RequestMaxBytes.WasSet { + input.RequestMaxBytes = &c.RequestMaxBytes.Value + } + + if c.TLSCACert.WasSet { + input.TLSCACert = &c.TLSCACert.Value + } + + if c.TLSClientCert.WasSet { + input.TLSClientCert = &c.TLSClientCert.Value + } + + if c.TLSClientKey.WasSet { + input.TLSClientKey = &c.TLSClientKey.Value + } + + if c.TLSHostname.WasSet { + input.TLSHostname = &c.TLSHostname.Value + } + + if c.Format.WasSet { + input.Format = fastly.ToPointer(argparser.Content(c.Format.Value)) + } + + if c.FormatVersion.WasSet { + input.FormatVersion = &c.FormatVersion.Value + } + + if c.ResponseCondition.WasSet { + input.ResponseCondition = &c.ResponseCondition.Value + } + + if c.Placement.WasSet { + input.Placement = &c.Placement.Value + } + + if c.MessageType.WasSet { + input.MessageType = &c.MessageType.Value + } + + return &input, nil +} + +// Exec invokes the application logic for the command. +func (c *UpdateCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.AutoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.ServiceName, + ServiceVersionFlag: c.ServiceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + input, err := c.ConstructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + https, err := c.Globals.APIClient.UpdateHTTPS(input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + text.Success(out, + "Updated HTTPS logging endpoint %s (service %s version %d)", + fastly.ToValue(https.Name), + fastly.ToValue(https.ServiceID), + fastly.ToValue(https.ServiceVersion), + ) + return nil +} diff --git a/pkg/commands/logging/kafka/create.go b/pkg/commands/logging/kafka/create.go new file mode 100644 index 000000000..c0877fe4c --- /dev/null +++ b/pkg/commands/logging/kafka/create.go @@ -0,0 +1,240 @@ +package kafka + +import ( + "fmt" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/logging/common" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/text" +) + +// CreateCommand calls the Fastly API to create a Kafka logging endpoint. +type CreateCommand struct { + argparser.Base + Manifest manifest.Data + + // Required. + ServiceName argparser.OptionalServiceNameID + ServiceVersion argparser.OptionalServiceVersion + + // Optional. + AuthMethod argparser.OptionalString + AutoClone argparser.OptionalAutoClone + Brokers argparser.OptionalString + CompressionCodec argparser.OptionalString + EndpointName argparser.OptionalString // Can't shadow argparser.Base method Name(). + Format argparser.OptionalString + FormatVersion argparser.OptionalInt + ParseLogKeyvals argparser.OptionalBool + Password argparser.OptionalString + Placement argparser.OptionalString + RequestMaxBytes argparser.OptionalInt + RequiredACKs argparser.OptionalString + ResponseCondition argparser.OptionalString + TLSCACert argparser.OptionalString + TLSClientCert argparser.OptionalString + TLSClientKey argparser.OptionalString + TLSHostname argparser.OptionalString + Topic argparser.OptionalString + User argparser.OptionalString + UseSASL argparser.OptionalBool + UseTLS argparser.OptionalBool +} + +// NewCreateCommand returns a usable command registered under the parent. +func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { + c := CreateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("create", "Create a Kafka logging endpoint on a Fastly service version").Alias("add") + + // Required. + c.CmdClause.Flag("name", "The name of the Kafka logging object. Used as a primary key for API access").Short('n').Action(c.EndpointName.Set).StringVar(&c.EndpointName.Value) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.ServiceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.AutoClone.Set, + Dst: &c.AutoClone.Value, + }) + c.CmdClause.Flag("auth-method", "SASL authentication method. Valid values are: plain, scram-sha-256, scram-sha-512").Action(c.AuthMethod.Set).HintOptions("plain", "scram-sha-256", "scram-sha-512").EnumVar(&c.AuthMethod.Value, "plain", "scram-sha-256", "scram-sha-512") + c.CmdClause.Flag("brokers", "A comma-separated list of IP addresses or hostnames of Kafka brokers").Action(c.Brokers.Set).StringVar(&c.Brokers.Value) + c.CmdClause.Flag("compression-codec", "The codec used for compression of your logs. One of: gzip, snappy, lz4").Action(c.CompressionCodec.Set).StringVar(&c.CompressionCodec.Value) + common.Format(c.CmdClause, &c.Format) + common.FormatVersion(c.CmdClause, &c.FormatVersion) + c.CmdClause.Flag("max-batch-size", "The maximum size of the log batch in bytes").Action(c.RequestMaxBytes.Set).IntVar(&c.RequestMaxBytes.Value) + c.CmdClause.Flag("parse-log-keyvals", "Parse key-value pairs within the log format").Action(c.ParseLogKeyvals.Set).BoolVar(&c.ParseLogKeyvals.Value) + c.CmdClause.Flag("password", "SASL authentication password. Required if --auth-method is specified").Action(c.Password.Set).StringVar(&c.Password.Value) + common.Placement(c.CmdClause, &c.Placement) + c.CmdClause.Flag("required-acks", "The Number of acknowledgements a leader must receive before a write is considered successful. One of: 1 (default) One server needs to respond. 0 No servers need to respond. -1 Wait for all in-sync replicas to respond").Action(c.RequiredACKs.Set).StringVar(&c.RequiredACKs.Value) + common.ResponseCondition(c.CmdClause, &c.ResponseCondition) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.ServiceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.ServiceName.Value, + }) + common.TLSCACert(c.CmdClause, &c.TLSCACert) + common.TLSClientCert(c.CmdClause, &c.TLSClientCert) + common.TLSClientKey(c.CmdClause, &c.TLSClientKey) + common.TLSHostname(c.CmdClause, &c.TLSHostname) + c.CmdClause.Flag("topic", "The Kafka topic to send logs to").Action(c.Topic.Set).StringVar(&c.Topic.Value) + c.CmdClause.Flag("use-sasl", "Enable SASL authentication. Requires --auth-method, --username, and --password to be specified").Action(c.UseSASL.Set).BoolVar(&c.UseSASL.Value) + c.CmdClause.Flag("use-tls", "Whether to use TLS for secure logging. Can be either true or false").Action(c.UseTLS.Set).BoolVar(&c.UseTLS.Value) + c.CmdClause.Flag("username", "SASL authentication username. Required if --auth-method is specified").Action(c.User.Set).StringVar(&c.User.Value) + return &c +} + +// ConstructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *CreateCommand) ConstructInput(serviceID string, serviceVersion int) (*fastly.CreateKafkaInput, error) { + var input fastly.CreateKafkaInput + + if c.UseSASL.WasSet && c.UseSASL.Value && (c.AuthMethod.Value == "" || c.User.Value == "" || c.Password.Value == "") { + return nil, fmt.Errorf("the --auth-method, --username, and --password flags must be present when using the --use-sasl flag") + } + + if !c.UseSASL.Value && (c.AuthMethod.Value != "" || c.User.Value != "" || c.Password.Value != "") { + return nil, fmt.Errorf("the --auth-method, --username, and --password options are only valid when the --use-sasl flag is specified") + } + + input.ServiceID = serviceID + input.ServiceVersion = serviceVersion + if c.EndpointName.WasSet { + input.Name = &c.EndpointName.Value + } + if c.Topic.WasSet { + input.Topic = &c.Topic.Value + } + if c.Brokers.WasSet { + input.Brokers = &c.Brokers.Value + } + + if c.CompressionCodec.WasSet { + input.CompressionCodec = &c.CompressionCodec.Value + } + + if c.RequiredACKs.WasSet { + input.RequiredACKs = &c.RequiredACKs.Value + } + + if c.UseTLS.WasSet { + input.UseTLS = fastly.ToPointer(fastly.Compatibool(c.UseTLS.Value)) + } + + if c.TLSCACert.WasSet { + input.TLSCACert = &c.TLSCACert.Value + } + + if c.TLSClientCert.WasSet { + input.TLSClientCert = &c.TLSClientCert.Value + } + + if c.TLSClientKey.WasSet { + input.TLSClientKey = &c.TLSClientKey.Value + } + + if c.TLSHostname.WasSet { + input.TLSHostname = &c.TLSHostname.Value + } + + if c.Format.WasSet { + input.Format = fastly.ToPointer(argparser.Content(c.Format.Value)) + } + + if c.FormatVersion.WasSet { + input.FormatVersion = &c.FormatVersion.Value + } + + if c.ResponseCondition.WasSet { + input.ResponseCondition = &c.ResponseCondition.Value + } + + if c.Placement.WasSet { + input.Placement = &c.Placement.Value + } + + if c.ParseLogKeyvals.WasSet { + input.ParseLogKeyvals = fastly.ToPointer(fastly.Compatibool(c.ParseLogKeyvals.Value)) + } + + if c.RequestMaxBytes.WasSet { + input.RequestMaxBytes = &c.RequestMaxBytes.Value + } + + if c.AuthMethod.WasSet { + input.AuthMethod = &c.AuthMethod.Value + } + + if c.User.WasSet { + input.User = &c.User.Value + } + + if c.Password.WasSet { + input.Password = &c.Password.Value + } + + return &input, nil +} + +// Exec invokes the application logic for the command. +func (c *CreateCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.AutoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.ServiceName, + ServiceVersionFlag: c.ServiceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + input, err := c.ConstructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + d, err := c.Globals.APIClient.CreateKafka(input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + text.Success(out, + "Created Kafka logging endpoint %s (service %s version %d)", + fastly.ToValue(d.Name), + fastly.ToValue(d.ServiceID), + fastly.ToValue(d.ServiceVersion), + ) + return nil +} diff --git a/pkg/commands/logging/kafka/delete.go b/pkg/commands/logging/kafka/delete.go new file mode 100644 index 000000000..1ddcff898 --- /dev/null +++ b/pkg/commands/logging/kafka/delete.go @@ -0,0 +1,94 @@ +package kafka + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// DeleteCommand calls the Fastly API to delete a Kafka logging endpoint. +type DeleteCommand struct { + argparser.Base + Input fastly.DeleteKafkaInput + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion + autoClone argparser.OptionalAutoClone +} + +// NewDeleteCommand returns a usable command registered under the parent. +func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { + c := DeleteCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("delete", "Delete a Kafka logging endpoint on a Fastly service version").Alias("remove") + + // Required. + c.CmdClause.Flag("name", "The name of the Kafka logging object").Short('n').Required().StringVar(&c.Input.Name) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.autoClone.Set, + Dst: &c.autoClone.Value, + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + return &c +} + +// Exec invokes the application logic for the command. +func (c *DeleteCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.autoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + c.Input.ServiceID = serviceID + c.Input.ServiceVersion = fastly.ToValue(serviceVersion.Number) + + if err := c.Globals.APIClient.DeleteKafka(&c.Input); err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + text.Success(out, "Deleted Kafka logging endpoint %s (service %s version %d)", c.Input.Name, c.Input.ServiceID, c.Input.ServiceVersion) + return nil +} diff --git a/pkg/commands/logging/kafka/describe.go b/pkg/commands/logging/kafka/describe.go new file mode 100644 index 000000000..a4218a2a5 --- /dev/null +++ b/pkg/commands/logging/kafka/describe.go @@ -0,0 +1,122 @@ +package kafka + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// DescribeCommand calls the Fastly API to describe a Kafka logging endpoint. +type DescribeCommand struct { + argparser.Base + argparser.JSONOutput + + Input fastly.GetKafkaInput + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion +} + +// NewDescribeCommand returns a usable command registered under the parent. +func NewDescribeCommand(parent argparser.Registerer, g *global.Data) *DescribeCommand { + c := DescribeCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("describe", "Show detailed information about a Kafka logging endpoint on a Fastly service version").Alias("get") + + // Required. + c.CmdClause.Flag("name", "The name of the Kafka logging object").Short('n').Required().StringVar(&c.Input.Name) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + return &c +} + +// Exec invokes the application logic for the command. +func (c *DescribeCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fsterr.ServiceVersion(serviceVersion), + }) + return err + } + + c.Input.ServiceID = serviceID + c.Input.ServiceVersion = fastly.ToValue(serviceVersion.Number) + + o, err := c.Globals.APIClient.GetKafka(&c.Input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + lines := text.Lines{ + "Brokers": fastly.ToValue(o.Brokers), + "Compression codec": fastly.ToValue(o.CompressionCodec), + "Format version": fastly.ToValue(o.FormatVersion), + "Format": fastly.ToValue(o.Format), + "Max batch size": fastly.ToValue(o.RequestMaxBytes), + "Name": fastly.ToValue(o.Name), + "Parse log key-values": fastly.ToValue(o.ParseLogKeyvals), + "Placement": fastly.ToValue(o.Placement), + "Required acks": fastly.ToValue(o.RequiredACKs), + "Response condition": fastly.ToValue(o.ResponseCondition), + "SASL authentication method": fastly.ToValue(o.AuthMethod), + "SASL authentication password": fastly.ToValue(o.Password), + "SASL authentication username": fastly.ToValue(o.User), + "TLS CA certificate": fastly.ToValue(o.TLSCACert), + "TLS client certificate": fastly.ToValue(o.TLSClientCert), + "TLS client key": fastly.ToValue(o.TLSClientKey), + "TLS hostname": fastly.ToValue(o.TLSHostname), + "Topic": fastly.ToValue(o.Topic), + "Use TLS": fastly.ToValue(o.UseTLS), + "Version": fastly.ToValue(o.ServiceVersion), + } + if !c.Globals.Verbose() { + lines["Service ID"] = fastly.ToValue(o.ServiceID) + } + text.PrintLines(out, lines) + + return nil +} diff --git a/pkg/logging/kafka/doc.go b/pkg/commands/logging/kafka/doc.go similarity index 100% rename from pkg/logging/kafka/doc.go rename to pkg/commands/logging/kafka/doc.go diff --git a/pkg/commands/logging/kafka/kafka_integration_test.go b/pkg/commands/logging/kafka/kafka_integration_test.go new file mode 100644 index 000000000..1d975b5f5 --- /dev/null +++ b/pkg/commands/logging/kafka/kafka_integration_test.go @@ -0,0 +1,534 @@ +package kafka_test + +import ( + "bytes" + "errors" + "io" + "strings" + "testing" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/app" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" +) + +func TestKafkaCreate(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging kafka create --service-id 123 --version 1 --name log --topic logs --brokers 127.0.0.1127.0.0.2 --parse-log-keyvals --max-batch-size 1024 --use-sasl --auth-method plain --username user --password password --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + CreateKafkaFn: createKafkaOK, + }, + wantOutput: "Created Kafka logging endpoint log (service 123 version 4)", + }, + { + args: args("logging kafka create --service-id 123 --version 1 --name log --topic logs --brokers 127.0.0.1127.0.0.2 --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + CreateKafkaFn: createKafkaError, + }, + wantError: errTest.Error(), + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertStringContains(t, stdout.String(), testcase.wantOutput) + }) + } +} + +func TestKafkaList(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging kafka list --service-id 123 --version 1"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListKafkasFn: listKafkasOK, + }, + wantOutput: listKafkasShortOutput, + }, + { + args: args("logging kafka list --service-id 123 --version 1 --verbose"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListKafkasFn: listKafkasOK, + }, + wantOutput: listKafkasVerboseOutput, + }, + { + args: args("logging kafka list --service-id 123 --version 1 -v"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListKafkasFn: listKafkasOK, + }, + wantOutput: listKafkasVerboseOutput, + }, + { + args: args("logging kafka --verbose list --service-id 123 --version 1"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListKafkasFn: listKafkasOK, + }, + wantOutput: listKafkasVerboseOutput, + }, + { + args: args("logging -v kafka list --service-id 123 --version 1"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListKafkasFn: listKafkasOK, + }, + wantOutput: listKafkasVerboseOutput, + }, + { + args: args("logging kafka list --service-id 123 --version 1"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListKafkasFn: listKafkasError, + }, + wantError: errTest.Error(), + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertString(t, testcase.wantOutput, stdout.String()) + }) + } +} + +func TestKafkaDescribe(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging kafka describe --service-id 123 --version 1"), + wantError: "error parsing arguments: required flag --name not provided", + }, + { + args: args("logging kafka describe --service-id 123 --version 1 --name logs"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + GetKafkaFn: getKafkaError, + }, + wantError: errTest.Error(), + }, + { + args: args("logging kafka describe --service-id 123 --version 1 --name logs"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + GetKafkaFn: getKafkaOK, + }, + wantOutput: describeKafkaOutput, + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertString(t, testcase.wantOutput, stdout.String()) + }) + } +} + +func TestKafkaUpdate(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging kafka update --service-id 123 --version 1 --new-name log"), + wantError: "error parsing arguments: required flag --name not provided", + }, + { + args: args("logging kafka update --service-id 123 --version 1 --name logs --new-name log --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + UpdateKafkaFn: updateKafkaError, + }, + wantError: errTest.Error(), + }, + { + args: args("logging kafka update --service-id 123 --version 1 --name logs --new-name log --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + UpdateKafkaFn: updateKafkaOK, + }, + wantOutput: "Updated Kafka logging endpoint log (service 123 version 4)", + }, + { + args: args("logging kafka update --service-id 123 --version 1 --name logs --new-name log --parse-log-keyvals --max-batch-size 1024 --use-sasl --auth-method plain --username user --password password --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + UpdateKafkaFn: updateKafkaSASL, + }, + wantOutput: "Updated Kafka logging endpoint log (service 123 version 4)", + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertStringContains(t, stdout.String(), testcase.wantOutput) + }) + } +} + +func TestKafkaDelete(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging kafka delete --service-id 123 --version 1"), + wantError: "error parsing arguments: required flag --name not provided", + }, + { + args: args("logging kafka delete --service-id 123 --version 1 --name logs --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + DeleteKafkaFn: deleteKafkaError, + }, + wantError: errTest.Error(), + }, + { + args: args("logging kafka delete --service-id 123 --version 1 --name logs --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + DeleteKafkaFn: deleteKafkaOK, + }, + wantOutput: "Deleted Kafka logging endpoint logs (service 123 version 4)", + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertStringContains(t, stdout.String(), testcase.wantOutput) + }) + } +} + +var errTest = errors.New("fixture error") + +func createKafkaOK(i *fastly.CreateKafkaInput) (*fastly.Kafka, error) { + return &fastly.Kafka{ + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("log"), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + Topic: fastly.ToPointer("logs"), + Brokers: fastly.ToPointer("127.0.0.1,127.0.0.2"), + RequiredACKs: fastly.ToPointer("-1"), + CompressionCodec: fastly.ToPointer("zippy"), + UseTLS: fastly.ToPointer(true), + Placement: fastly.ToPointer("none"), + TLSCACert: fastly.ToPointer("-----BEGIN CERTIFICATE-----foo"), + TLSHostname: fastly.ToPointer("127.0.0.1,127.0.0.2"), + TLSClientCert: fastly.ToPointer("-----BEGIN CERTIFICATE-----bar"), + TLSClientKey: fastly.ToPointer("-----BEGIN PRIVATE KEY-----bar"), + FormatVersion: fastly.ToPointer(2), + ParseLogKeyvals: fastly.ToPointer(true), + RequestMaxBytes: fastly.ToPointer(1024), + AuthMethod: fastly.ToPointer("plain"), + User: fastly.ToPointer("user"), + Password: fastly.ToPointer("password"), + }, nil +} + +func createKafkaError(_ *fastly.CreateKafkaInput) (*fastly.Kafka, error) { + return nil, errTest +} + +func listKafkasOK(i *fastly.ListKafkasInput) ([]*fastly.Kafka, error) { + return []*fastly.Kafka{ + { + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("logs"), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + Topic: fastly.ToPointer("logs"), + Brokers: fastly.ToPointer("127.0.0.1,127.0.0.2"), + RequiredACKs: fastly.ToPointer("-1"), + CompressionCodec: fastly.ToPointer("zippy"), + UseTLS: fastly.ToPointer(true), + Placement: fastly.ToPointer("none"), + TLSCACert: fastly.ToPointer("-----BEGIN CERTIFICATE-----foo"), + TLSHostname: fastly.ToPointer("127.0.0.1,127.0.0.2"), + TLSClientCert: fastly.ToPointer("-----BEGIN CERTIFICATE-----bar"), + TLSClientKey: fastly.ToPointer("-----BEGIN PRIVATE KEY-----bar"), + FormatVersion: fastly.ToPointer(2), + ParseLogKeyvals: fastly.ToPointer(false), + RequestMaxBytes: fastly.ToPointer(0), + AuthMethod: fastly.ToPointer("plain"), + User: fastly.ToPointer("user"), + Password: fastly.ToPointer("password"), + }, + { + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("analytics"), + Topic: fastly.ToPointer("analytics"), + Brokers: fastly.ToPointer("127.0.0.1,127.0.0.2"), + RequiredACKs: fastly.ToPointer("-1"), + CompressionCodec: fastly.ToPointer("zippy"), + UseTLS: fastly.ToPointer(true), + Placement: fastly.ToPointer("none"), + TLSCACert: fastly.ToPointer("-----BEGIN CERTIFICATE-----foo"), + TLSHostname: fastly.ToPointer("127.0.0.1,127.0.0.2"), + TLSClientCert: fastly.ToPointer("-----BEGIN CERTIFICATE-----bar"), + TLSClientKey: fastly.ToPointer("-----BEGIN PRIVATE KEY-----bar"), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + FormatVersion: fastly.ToPointer(2), + ParseLogKeyvals: fastly.ToPointer(false), + RequestMaxBytes: fastly.ToPointer(0), + AuthMethod: fastly.ToPointer("plain"), + User: fastly.ToPointer("user"), + Password: fastly.ToPointer("password"), + }, + }, nil +} + +func listKafkasError(_ *fastly.ListKafkasInput) ([]*fastly.Kafka, error) { + return nil, errTest +} + +var listKafkasShortOutput = strings.TrimSpace(` +SERVICE VERSION NAME +123 1 logs +123 1 analytics +`) + "\n" + +var listKafkasVerboseOutput = strings.TrimSpace(` +Fastly API endpoint: https://api.fastly.com +Fastly API token provided via config file (profile: user) + +Service ID (via --service-id): 123 + +Version: 1 + Kafka 1/2 + Service ID: 123 + Version: 1 + Name: logs + Topic: logs + Brokers: 127.0.0.1,127.0.0.2 + Required acks: -1 + Compression codec: zippy + Use TLS: true + TLS CA certificate: -----BEGIN CERTIFICATE-----foo + TLS client certificate: -----BEGIN CERTIFICATE-----bar + TLS client key: -----BEGIN PRIVATE KEY-----bar + TLS hostname: 127.0.0.1,127.0.0.2 + Format: %h %l %u %t "%r" %>s %b + Format version: 2 + Response condition: Prevent default logging + Placement: none + Parse log key-values: false + Max batch size: 0 + SASL authentication method: plain + SASL authentication username: user + SASL authentication password: password + Kafka 2/2 + Service ID: 123 + Version: 1 + Name: analytics + Topic: analytics + Brokers: 127.0.0.1,127.0.0.2 + Required acks: -1 + Compression codec: zippy + Use TLS: true + TLS CA certificate: -----BEGIN CERTIFICATE-----foo + TLS client certificate: -----BEGIN CERTIFICATE-----bar + TLS client key: -----BEGIN PRIVATE KEY-----bar + TLS hostname: 127.0.0.1,127.0.0.2 + Format: %h %l %u %t "%r" %>s %b + Format version: 2 + Response condition: Prevent default logging + Placement: none + Parse log key-values: false + Max batch size: 0 + SASL authentication method: plain + SASL authentication username: user + SASL authentication password: password + `) + "\n\n" + +func getKafkaOK(i *fastly.GetKafkaInput) (*fastly.Kafka, error) { + return &fastly.Kafka{ + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("log"), + Brokers: fastly.ToPointer("127.0.0.1,127.0.0.2"), + Topic: fastly.ToPointer("logs"), + RequiredACKs: fastly.ToPointer("-1"), + UseTLS: fastly.ToPointer(true), + CompressionCodec: fastly.ToPointer("zippy"), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + FormatVersion: fastly.ToPointer(2), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + Placement: fastly.ToPointer("none"), + TLSCACert: fastly.ToPointer("-----BEGIN CERTIFICATE-----foo"), + TLSHostname: fastly.ToPointer("127.0.0.1,127.0.0.2"), + TLSClientCert: fastly.ToPointer("-----BEGIN CERTIFICATE-----bar"), + TLSClientKey: fastly.ToPointer("-----BEGIN PRIVATE KEY-----bar"), + }, nil +} + +func getKafkaError(_ *fastly.GetKafkaInput) (*fastly.Kafka, error) { + return nil, errTest +} + +var describeKafkaOutput = ` +Brokers: 127.0.0.1,127.0.0.2 +Compression codec: zippy +Format: %h %l %u %t "%r" %>s %b +Format version: 2 +Max batch size: 0 +Name: log +Parse log key-values: false +Placement: none +Required acks: -1 +Response condition: Prevent default logging +SASL authentication method: ` + ` +SASL authentication password: ` + ` +SASL authentication username: ` + ` +Service ID: 123 +TLS CA certificate: -----BEGIN CERTIFICATE-----foo +TLS client certificate: -----BEGIN CERTIFICATE-----bar +TLS client key: -----BEGIN PRIVATE KEY-----bar +TLS hostname: 127.0.0.1,127.0.0.2 +Topic: logs +Use TLS: true +Version: 1 +` + +func updateKafkaOK(i *fastly.UpdateKafkaInput) (*fastly.Kafka, error) { + return &fastly.Kafka{ + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("log"), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + Topic: fastly.ToPointer("logs"), + Brokers: fastly.ToPointer("127.0.0.1,127.0.0.2"), + RequiredACKs: fastly.ToPointer("-1"), + CompressionCodec: fastly.ToPointer("zippy"), + UseTLS: fastly.ToPointer(true), + Placement: fastly.ToPointer("none"), + TLSCACert: fastly.ToPointer("-----BEGIN CERTIFICATE-----foo"), + TLSHostname: fastly.ToPointer("127.0.0.1,127.0.0.2"), + TLSClientCert: fastly.ToPointer("-----BEGIN CERTIFICATE-----bar"), + TLSClientKey: fastly.ToPointer("-----BEGIN PRIVATE KEY-----bar"), + FormatVersion: fastly.ToPointer(2), + }, nil +} + +func updateKafkaSASL(i *fastly.UpdateKafkaInput) (*fastly.Kafka, error) { + return &fastly.Kafka{ + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("log"), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + Topic: fastly.ToPointer("logs"), + Brokers: fastly.ToPointer("127.0.0.1,127.0.0.2"), + RequiredACKs: fastly.ToPointer("-1"), + CompressionCodec: fastly.ToPointer("zippy"), + UseTLS: fastly.ToPointer(true), + Placement: fastly.ToPointer("none"), + TLSCACert: fastly.ToPointer("-----BEGIN CERTIFICATE-----foo"), + TLSHostname: fastly.ToPointer("127.0.0.1,127.0.0.2"), + TLSClientCert: fastly.ToPointer("-----BEGIN CERTIFICATE-----bar"), + TLSClientKey: fastly.ToPointer("-----BEGIN PRIVATE KEY-----bar"), + FormatVersion: fastly.ToPointer(2), + ParseLogKeyvals: fastly.ToPointer(true), + RequestMaxBytes: fastly.ToPointer(1024), + AuthMethod: fastly.ToPointer("plain"), + User: fastly.ToPointer("user"), + Password: fastly.ToPointer("password"), + }, nil +} + +func updateKafkaError(_ *fastly.UpdateKafkaInput) (*fastly.Kafka, error) { + return nil, errTest +} + +func deleteKafkaOK(_ *fastly.DeleteKafkaInput) error { + return nil +} + +func deleteKafkaError(_ *fastly.DeleteKafkaInput) error { + return errTest +} diff --git a/pkg/commands/logging/kafka/kafka_test.go b/pkg/commands/logging/kafka/kafka_test.go new file mode 100644 index 000000000..068d5b237 --- /dev/null +++ b/pkg/commands/logging/kafka/kafka_test.go @@ -0,0 +1,591 @@ +package kafka_test + +import ( + "bytes" + "testing" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/logging/kafka" + "github.com/fastly/cli/pkg/config" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" +) + +func TestCreateKafkaInput(t *testing.T) { + for _, testcase := range []struct { + name string + cmd *kafka.CreateCommand + want *fastly.CreateKafkaInput + wantError string + }{ + { + name: "required values set flag serviceID", + cmd: createCommandRequired(), + want: &fastly.CreateKafkaInput{ + ServiceID: "123", + ServiceVersion: 4, + Name: fastly.ToPointer("log"), + Topic: fastly.ToPointer("logs"), + Brokers: fastly.ToPointer("127.0.0.1,127.0.0.2"), + }, + }, + { + name: "all values set flag serviceID", + cmd: createCommandAll(), + want: &fastly.CreateKafkaInput{ + ServiceID: "123", + ServiceVersion: 4, + Name: fastly.ToPointer("logs"), + Brokers: fastly.ToPointer("127.0.0.1,127.0.0.2"), + Topic: fastly.ToPointer("logs"), + RequiredACKs: fastly.ToPointer("-1"), + UseTLS: fastly.ToPointer(fastly.Compatibool(true)), + CompressionCodec: fastly.ToPointer("zippy"), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + FormatVersion: fastly.ToPointer(2), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + Placement: fastly.ToPointer("none"), + TLSCACert: fastly.ToPointer("-----BEGIN CERTIFICATE-----foo"), + TLSHostname: fastly.ToPointer("example.com"), + TLSClientCert: fastly.ToPointer("-----BEGIN CERTIFICATE-----bar"), + TLSClientKey: fastly.ToPointer("-----BEGIN PRIVATE KEY-----bar"), + }, + }, + { + name: "error missing serviceID", + cmd: createCommandMissingServiceID(), + wantError: errors.ErrNoServiceID.Error(), + }, + { + name: "verify SASL fields", + cmd: createCommandSASL("scram-sha-512", "user1", "12345"), + want: &fastly.CreateKafkaInput{ + ServiceID: "123", + ServiceVersion: 4, + Name: fastly.ToPointer("log"), + Topic: fastly.ToPointer("logs"), + Brokers: fastly.ToPointer("127.0.0.1,127.0.0.2"), + ParseLogKeyvals: fastly.ToPointer(fastly.Compatibool(true)), + RequestMaxBytes: fastly.ToPointer(11111), + AuthMethod: fastly.ToPointer("scram-sha-512"), + User: fastly.ToPointer("user1"), + Password: fastly.ToPointer("12345"), + }, + }, + } { + t.Run(testcase.name, func(t *testing.T) { + var bs []byte + out := bytes.NewBuffer(bs) + verboseMode := true + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + AutoCloneFlag: testcase.cmd.AutoClone, + APIClient: testcase.cmd.Globals.APIClient, + Manifest: testcase.cmd.Manifest, + Out: out, + ServiceVersionFlag: testcase.cmd.ServiceVersion, + VerboseMode: verboseMode, + }) + + switch { + case err != nil && testcase.wantError == "": + t.Fatalf("unexpected error getting service details: %v", err) + return + case err != nil && testcase.wantError != "": + testutil.AssertErrorContains(t, err, testcase.wantError) + return + case err == nil && testcase.wantError != "": + t.Fatalf("expected error, have nil (service details: %s, %d)", serviceID, serviceVersion.Number) + case err == nil && testcase.wantError == "": + have, err := testcase.cmd.ConstructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertEqual(t, testcase.want, have) + } + }) + } +} + +func TestUpdateKafkaInput(t *testing.T) { + scenarios := []struct { + name string + cmd *kafka.UpdateCommand + api mock.API + want *fastly.UpdateKafkaInput + wantError string + }{ + { + name: "all values set flag serviceID", + cmd: updateCommandAll(), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + GetKafkaFn: getKafkaOK, + }, + want: &fastly.UpdateKafkaInput{ + ServiceID: "123", + ServiceVersion: 4, + Name: "log", + NewName: fastly.ToPointer("new1"), + Topic: fastly.ToPointer("new2"), + Brokers: fastly.ToPointer("new3"), + RequiredACKs: fastly.ToPointer("new4"), + UseTLS: fastly.ToPointer(fastly.Compatibool(false)), + CompressionCodec: fastly.ToPointer("new5"), + Placement: fastly.ToPointer("new6"), + Format: fastly.ToPointer("new7"), + FormatVersion: fastly.ToPointer(3), + ResponseCondition: fastly.ToPointer("new8"), + TLSCACert: fastly.ToPointer("new9"), + TLSClientCert: fastly.ToPointer("new10"), + TLSClientKey: fastly.ToPointer("new11"), + TLSHostname: fastly.ToPointer("new12"), + ParseLogKeyvals: fastly.ToPointer(fastly.Compatibool(false)), + RequestMaxBytes: fastly.ToPointer(22222), + AuthMethod: fastly.ToPointer("plain"), + User: fastly.ToPointer("new13"), + Password: fastly.ToPointer("new14"), + }, + }, + { + name: "no updates", + cmd: updateCommandNoUpdates(), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + GetKafkaFn: getKafkaOK, + }, + want: &fastly.UpdateKafkaInput{ + ServiceID: "123", + ServiceVersion: 4, + Name: "log", + }, + }, + { + name: "error missing serviceID", + cmd: updateCommandMissingServiceID(), + want: nil, + wantError: errors.ErrNoServiceID.Error(), + }, + { + name: "verify SASL fields", + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + GetKafkaFn: getKafkaOK, + }, + cmd: updateCommandSASL("scram-sha-512", "user1", "12345"), + want: &fastly.UpdateKafkaInput{ + ServiceID: "123", + ServiceVersion: 4, + Name: "log", + Topic: fastly.ToPointer("logs"), + Brokers: fastly.ToPointer("127.0.0.1,127.0.0.2"), + ParseLogKeyvals: fastly.ToPointer(fastly.Compatibool(true)), + RequestMaxBytes: fastly.ToPointer(11111), + AuthMethod: fastly.ToPointer("scram-sha-512"), + User: fastly.ToPointer("user1"), + Password: fastly.ToPointer("12345"), + }, + }, + { + name: "verify disabling SASL", + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + GetKafkaFn: getKafkaSASL, + }, + cmd: updateCommandNoSASL(), + want: &fastly.UpdateKafkaInput{ + ServiceID: "123", + ServiceVersion: 4, + Name: "log", + Topic: fastly.ToPointer("logs"), + Brokers: fastly.ToPointer("127.0.0.1,127.0.0.2"), + ParseLogKeyvals: fastly.ToPointer(fastly.Compatibool(true)), + RequestMaxBytes: fastly.ToPointer(11111), + AuthMethod: fastly.ToPointer(""), + User: fastly.ToPointer(""), + Password: fastly.ToPointer(""), + }, + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(testcase.name, func(t *testing.T) { + testcase.cmd.Globals.APIClient = testcase.api + + var bs []byte + out := bytes.NewBuffer(bs) + verboseMode := true + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + AutoCloneFlag: testcase.cmd.AutoClone, + APIClient: testcase.api, + Manifest: testcase.cmd.Manifest, + Out: out, + ServiceVersionFlag: testcase.cmd.ServiceVersion, + VerboseMode: verboseMode, + }) + + switch { + case err != nil && testcase.wantError == "": + t.Fatalf("unexpected error getting service details: %v", err) + return + case err != nil && testcase.wantError != "": + testutil.AssertErrorContains(t, err, testcase.wantError) + return + case err == nil && testcase.wantError != "": + t.Fatalf("expected error, have nil (service details: %s, %d)", serviceID, serviceVersion.Number) + case err == nil && testcase.wantError == "": + have, err := testcase.cmd.ConstructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertEqual(t, testcase.want, have) + } + }) + } +} + +func createCommandRequired() *kafka.CreateCommand { + var b bytes.Buffer + + g := global.Data{ + Config: config.File{}, + Env: config.Environment{}, + Output: &b, + } + g.APIClient, _ = mock.APIClient(mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + })("token", "endpoint", false) + + return &kafka.CreateCommand{ + Base: argparser.Base{ + Globals: &g, + }, + Manifest: manifest.Data{ + Flag: manifest.Flag{ + ServiceID: "123", + }, + }, + ServiceVersion: argparser.OptionalServiceVersion{ + OptionalString: argparser.OptionalString{Value: "1"}, + }, + AutoClone: argparser.OptionalAutoClone{ + OptionalBool: argparser.OptionalBool{ + Optional: argparser.Optional{ + WasSet: true, + }, + Value: true, + }, + }, + EndpointName: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "log"}, + Topic: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "logs"}, + Brokers: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "127.0.0.1,127.0.0.2"}, + } +} + +func createCommandAll() *kafka.CreateCommand { + var b bytes.Buffer + + g := global.Data{ + Config: config.File{}, + Env: config.Environment{}, + Output: &b, + } + g.APIClient, _ = mock.APIClient(mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + })("token", "endpoint", false) + + return &kafka.CreateCommand{ + Base: argparser.Base{ + Globals: &g, + }, + Manifest: manifest.Data{ + Flag: manifest.Flag{ + ServiceID: "123", + }, + }, + ServiceVersion: argparser.OptionalServiceVersion{ + OptionalString: argparser.OptionalString{Value: "1"}, + }, + AutoClone: argparser.OptionalAutoClone{ + OptionalBool: argparser.OptionalBool{ + Optional: argparser.Optional{ + WasSet: true, + }, + Value: true, + }, + }, + EndpointName: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "logs"}, + Topic: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "logs"}, + Brokers: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "127.0.0.1,127.0.0.2"}, + UseTLS: argparser.OptionalBool{Optional: argparser.Optional{WasSet: true}, Value: true}, + RequiredACKs: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "-1"}, + CompressionCodec: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "zippy"}, + Format: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: `%h %l %u %t "%r" %>s %b`}, + FormatVersion: argparser.OptionalInt{Optional: argparser.Optional{WasSet: true}, Value: 2}, + ResponseCondition: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "Prevent default logging"}, + Placement: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "none"}, + TLSCACert: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "-----BEGIN CERTIFICATE-----foo"}, + TLSHostname: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "example.com"}, + TLSClientCert: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "-----BEGIN CERTIFICATE-----bar"}, + TLSClientKey: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "-----BEGIN PRIVATE KEY-----bar"}, + } +} + +func createCommandSASL(authMethod, user, password string) *kafka.CreateCommand { + var b bytes.Buffer + + g := global.Data{ + Config: config.File{}, + Env: config.Environment{}, + Output: &b, + } + g.APIClient, _ = mock.APIClient(mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + })("token", "endpoint", false) + + return &kafka.CreateCommand{ + Base: argparser.Base{ + Globals: &g, + }, + Manifest: manifest.Data{ + Flag: manifest.Flag{ + ServiceID: "123", + }, + }, + ServiceVersion: argparser.OptionalServiceVersion{ + OptionalString: argparser.OptionalString{Value: "1"}, + }, + AutoClone: argparser.OptionalAutoClone{ + OptionalBool: argparser.OptionalBool{ + Optional: argparser.Optional{ + WasSet: true, + }, + Value: true, + }, + }, + EndpointName: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "log"}, + Topic: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "logs"}, + Brokers: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "127.0.0.1,127.0.0.2"}, + ParseLogKeyvals: argparser.OptionalBool{Optional: argparser.Optional{WasSet: true}, Value: true}, + RequestMaxBytes: argparser.OptionalInt{Optional: argparser.Optional{WasSet: true}, Value: 11111}, + UseSASL: argparser.OptionalBool{Optional: argparser.Optional{WasSet: true}, Value: true}, + AuthMethod: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: authMethod}, + User: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: user}, + Password: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: password}, + } +} + +func createCommandMissingServiceID() *kafka.CreateCommand { + res := createCommandAll() + res.Manifest = manifest.Data{} + return res +} + +func updateCommandNoUpdates() *kafka.UpdateCommand { + var b bytes.Buffer + + g := global.Data{ + Config: config.File{}, + Env: config.Environment{}, + Output: &b, + } + + return &kafka.UpdateCommand{ + Base: argparser.Base{ + Globals: &g, + }, + Manifest: manifest.Data{ + Flag: manifest.Flag{ + ServiceID: "123", + }, + }, + EndpointName: "log", + ServiceVersion: argparser.OptionalServiceVersion{ + OptionalString: argparser.OptionalString{Value: "1"}, + }, + AutoClone: argparser.OptionalAutoClone{ + OptionalBool: argparser.OptionalBool{ + Optional: argparser.Optional{ + WasSet: true, + }, + Value: true, + }, + }, + } +} + +func updateCommandAll() *kafka.UpdateCommand { + var b bytes.Buffer + + g := global.Data{ + Config: config.File{}, + Env: config.Environment{}, + Output: &b, + } + + return &kafka.UpdateCommand{ + Base: argparser.Base{ + Globals: &g, + }, + Manifest: manifest.Data{ + Flag: manifest.Flag{ + ServiceID: "123", + }, + }, + EndpointName: "log", + ServiceVersion: argparser.OptionalServiceVersion{ + OptionalString: argparser.OptionalString{Value: "1"}, + }, + AutoClone: argparser.OptionalAutoClone{ + OptionalBool: argparser.OptionalBool{ + Optional: argparser.Optional{ + WasSet: true, + }, + Value: true, + }, + }, + NewName: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new1"}, + Topic: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new2"}, + Brokers: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new3"}, + UseTLS: argparser.OptionalBool{Optional: argparser.Optional{WasSet: true}, Value: false}, + RequiredACKs: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new4"}, + CompressionCodec: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new5"}, + Placement: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new6"}, + Format: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new7"}, + FormatVersion: argparser.OptionalInt{Optional: argparser.Optional{WasSet: true}, Value: 3}, + ResponseCondition: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new8"}, + TLSCACert: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new9"}, + TLSClientCert: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new10"}, + TLSClientKey: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new11"}, + TLSHostname: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new12"}, + ParseLogKeyvals: argparser.OptionalBool{Optional: argparser.Optional{WasSet: true}, Value: false}, + RequestMaxBytes: argparser.OptionalInt{Optional: argparser.Optional{WasSet: true}, Value: 22222}, + UseSASL: argparser.OptionalBool{Optional: argparser.Optional{WasSet: true}, Value: true}, + AuthMethod: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "plain"}, + User: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new13"}, + Password: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new14"}, + } +} + +func updateCommandSASL(authMethod, user, password string) *kafka.UpdateCommand { + var b bytes.Buffer + + g := global.Data{ + Config: config.File{}, + Env: config.Environment{}, + Output: &b, + } + + return &kafka.UpdateCommand{ + Base: argparser.Base{ + Globals: &g, + }, + Manifest: manifest.Data{ + Flag: manifest.Flag{ + ServiceID: "123", + }, + }, + EndpointName: "log", + ServiceVersion: argparser.OptionalServiceVersion{ + OptionalString: argparser.OptionalString{Value: "1"}, + }, + AutoClone: argparser.OptionalAutoClone{ + OptionalBool: argparser.OptionalBool{ + Optional: argparser.Optional{ + WasSet: true, + }, + Value: true, + }, + }, + Topic: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "logs"}, + Brokers: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "127.0.0.1,127.0.0.2"}, + ParseLogKeyvals: argparser.OptionalBool{Optional: argparser.Optional{WasSet: true}, Value: true}, + RequestMaxBytes: argparser.OptionalInt{Optional: argparser.Optional{WasSet: true}, Value: 11111}, + UseSASL: argparser.OptionalBool{Optional: argparser.Optional{WasSet: true}, Value: true}, + AuthMethod: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: authMethod}, + User: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: user}, + Password: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: password}, + } +} + +func updateCommandNoSASL() *kafka.UpdateCommand { + var b bytes.Buffer + + g := global.Data{ + Config: config.File{}, + Env: config.Environment{}, + Output: &b, + } + + return &kafka.UpdateCommand{ + Base: argparser.Base{ + Globals: &g, + }, + Manifest: manifest.Data{ + Flag: manifest.Flag{ + ServiceID: "123", + }, + }, + EndpointName: "log", + ServiceVersion: argparser.OptionalServiceVersion{ + OptionalString: argparser.OptionalString{Value: "1"}, + }, + AutoClone: argparser.OptionalAutoClone{ + OptionalBool: argparser.OptionalBool{ + Optional: argparser.Optional{ + WasSet: true, + }, + Value: true, + }, + }, + Topic: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "logs"}, + Brokers: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "127.0.0.1,127.0.0.2"}, + ParseLogKeyvals: argparser.OptionalBool{Optional: argparser.Optional{WasSet: true}, Value: true}, + RequestMaxBytes: argparser.OptionalInt{Optional: argparser.Optional{WasSet: true}, Value: 11111}, + UseSASL: argparser.OptionalBool{Optional: argparser.Optional{WasSet: true}, Value: false}, + AuthMethod: argparser.OptionalString{Optional: argparser.Optional{WasSet: false}, Value: ""}, + User: argparser.OptionalString{Optional: argparser.Optional{WasSet: false}, Value: ""}, + Password: argparser.OptionalString{Optional: argparser.Optional{WasSet: false}, Value: ""}, + } +} + +func updateCommandMissingServiceID() *kafka.UpdateCommand { + res := updateCommandAll() + res.Manifest = manifest.Data{} + return res +} + +func getKafkaSASL(i *fastly.GetKafkaInput) (*fastly.Kafka, error) { + return &fastly.Kafka{ + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("log"), + Brokers: fastly.ToPointer("127.0.0.1,127.0.0.2"), + Topic: fastly.ToPointer("logs"), + RequiredACKs: fastly.ToPointer("-1"), + UseTLS: fastly.ToPointer(true), + CompressionCodec: fastly.ToPointer("zippy"), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + FormatVersion: fastly.ToPointer(2), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + Placement: fastly.ToPointer("none"), + TLSCACert: fastly.ToPointer("-----BEGIN CERTIFICATE-----foo"), + TLSHostname: fastly.ToPointer("example.com"), + TLSClientCert: fastly.ToPointer("-----BEGIN CERTIFICATE-----bar"), + TLSClientKey: fastly.ToPointer("-----BEGIN PRIVATE KEY-----bar"), + ParseLogKeyvals: fastly.ToPointer(false), + RequestMaxBytes: fastly.ToPointer(0), + AuthMethod: fastly.ToPointer("plain"), + User: fastly.ToPointer("user"), + Password: fastly.ToPointer("password"), + }, nil +} diff --git a/pkg/commands/logging/kafka/list.go b/pkg/commands/logging/kafka/list.go new file mode 100644 index 000000000..e47a3a05c --- /dev/null +++ b/pkg/commands/logging/kafka/list.go @@ -0,0 +1,136 @@ +package kafka + +import ( + "fmt" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// ListCommand calls the Fastly API to list Kafka logging endpoints. +type ListCommand struct { + argparser.Base + argparser.JSONOutput + + Input fastly.ListKafkasInput + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion +} + +// NewListCommand returns a usable command registered under the parent. +func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { + c := ListCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("list", "List Kafka endpoints on a Fastly service version") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + return &c +} + +// Exec invokes the application logic for the command. +func (c *ListCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fsterr.ServiceVersion(serviceVersion), + }) + return err + } + + c.Input.ServiceID = serviceID + c.Input.ServiceVersion = fastly.ToValue(serviceVersion.Number) + + o, err := c.Globals.APIClient.ListKafkas(&c.Input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + if !c.Globals.Verbose() { + tw := text.NewTable(out) + tw.AddHeader("SERVICE", "VERSION", "NAME") + for _, kafka := range o { + tw.AddLine( + fastly.ToValue(kafka.ServiceID), + fastly.ToValue(kafka.ServiceVersion), + fastly.ToValue(kafka.Name), + ) + } + tw.Print() + return nil + } + + fmt.Fprintf(out, "Version: %d\n", c.Input.ServiceVersion) + for i, kafka := range o { + fmt.Fprintf(out, "\tKafka %d/%d\n", i+1, len(o)) + fmt.Fprintf(out, "\t\tService ID: %s\n", fastly.ToValue(kafka.ServiceID)) + fmt.Fprintf(out, "\t\tVersion: %d\n", fastly.ToValue(kafka.ServiceVersion)) + fmt.Fprintf(out, "\t\tName: %s\n", fastly.ToValue(kafka.Name)) + fmt.Fprintf(out, "\t\tTopic: %s\n", fastly.ToValue(kafka.Topic)) + fmt.Fprintf(out, "\t\tBrokers: %s\n", fastly.ToValue(kafka.Brokers)) + fmt.Fprintf(out, "\t\tRequired acks: %s\n", fastly.ToValue(kafka.RequiredACKs)) + fmt.Fprintf(out, "\t\tCompression codec: %s\n", fastly.ToValue(kafka.CompressionCodec)) + fmt.Fprintf(out, "\t\tUse TLS: %t\n", fastly.ToValue(kafka.UseTLS)) + fmt.Fprintf(out, "\t\tTLS CA certificate: %s\n", fastly.ToValue(kafka.TLSCACert)) + fmt.Fprintf(out, "\t\tTLS client certificate: %s\n", fastly.ToValue(kafka.TLSClientCert)) + fmt.Fprintf(out, "\t\tTLS client key: %s\n", fastly.ToValue(kafka.TLSClientKey)) + fmt.Fprintf(out, "\t\tTLS hostname: %s\n", fastly.ToValue(kafka.TLSHostname)) + fmt.Fprintf(out, "\t\tFormat: %s\n", fastly.ToValue(kafka.Format)) + fmt.Fprintf(out, "\t\tFormat version: %d\n", fastly.ToValue(kafka.FormatVersion)) + fmt.Fprintf(out, "\t\tResponse condition: %s\n", fastly.ToValue(kafka.ResponseCondition)) + fmt.Fprintf(out, "\t\tPlacement: %s\n", fastly.ToValue(kafka.Placement)) + fmt.Fprintf(out, "\t\tParse log key-values: %t\n", fastly.ToValue(kafka.ParseLogKeyvals)) + fmt.Fprintf(out, "\t\tMax batch size: %d\n", fastly.ToValue(kafka.RequestMaxBytes)) + fmt.Fprintf(out, "\t\tSASL authentication method: %s\n", fastly.ToValue(kafka.AuthMethod)) + fmt.Fprintf(out, "\t\tSASL authentication username: %s\n", fastly.ToValue(kafka.User)) + fmt.Fprintf(out, "\t\tSASL authentication password: %s\n", fastly.ToValue(kafka.Password)) + } + fmt.Fprintln(out) + + return nil +} diff --git a/pkg/commands/logging/kafka/root.go b/pkg/commands/logging/kafka/root.go new file mode 100644 index 000000000..f1a1971dd --- /dev/null +++ b/pkg/commands/logging/kafka/root.go @@ -0,0 +1,31 @@ +package kafka + +import ( + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" +) + +// RootCommand is the parent command for all subcommands in this package. +// It should be installed under the primary root command. +type RootCommand struct { + argparser.Base + // no flags +} + +// CommandName is the string to be used to invoke this command. +const CommandName = "kafka" + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent argparser.Registerer, g *global.Data) *RootCommand { + var c RootCommand + c.Globals = g + c.CmdClause = parent.Command(CommandName, "Manipulate Fastly service version Kafka logging endpoints") + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { + panic("unreachable") +} diff --git a/pkg/commands/logging/kafka/update.go b/pkg/commands/logging/kafka/update.go new file mode 100644 index 000000000..96879cc00 --- /dev/null +++ b/pkg/commands/logging/kafka/update.go @@ -0,0 +1,253 @@ +package kafka + +import ( + "fmt" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/logging/common" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/text" +) + +// UpdateCommand calls the Fastly API to update a Kafka logging endpoint. +type UpdateCommand struct { + argparser.Base + Manifest manifest.Data + + // Required. + EndpointName string // Can't shadow argparser.Base method Name(). + ServiceName argparser.OptionalServiceNameID + ServiceVersion argparser.OptionalServiceVersion + + // Optional. + AutoClone argparser.OptionalAutoClone + NewName argparser.OptionalString + Index argparser.OptionalString + Topic argparser.OptionalString + Brokers argparser.OptionalString + UseTLS argparser.OptionalBool + CompressionCodec argparser.OptionalString + RequiredACKs argparser.OptionalString + TLSCACert argparser.OptionalString + TLSClientCert argparser.OptionalString + TLSClientKey argparser.OptionalString + TLSHostname argparser.OptionalString + Format argparser.OptionalString + FormatVersion argparser.OptionalInt + Placement argparser.OptionalString + ResponseCondition argparser.OptionalString + ParseLogKeyvals argparser.OptionalBool + RequestMaxBytes argparser.OptionalInt + UseSASL argparser.OptionalBool + AuthMethod argparser.OptionalString + User argparser.OptionalString + Password argparser.OptionalString +} + +// NewUpdateCommand returns a usable command registered under the parent. +func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { + c := UpdateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("update", "Update a Kafka logging endpoint on a Fastly service version") + + // Required. + c.CmdClause.Flag("name", "The name of the Kafka logging object").Short('n').Required().StringVar(&c.EndpointName) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.ServiceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.AutoClone.Set, + Dst: &c.AutoClone.Value, + }) + c.CmdClause.Flag("auth-method", "SASL authentication method. Valid values are: plain, scram-sha-256, scram-sha-512").Action(c.AuthMethod.Set).HintOptions("plain", "scram-sha-256", "scram-sha-512").EnumVar(&c.AuthMethod.Value, "plain", "scram-sha-256", "scram-sha-512") + c.CmdClause.Flag("brokers", "A comma-separated list of IP addresses or hostnames of Kafka brokers").Action(c.Brokers.Set).StringVar(&c.Brokers.Value) + c.CmdClause.Flag("compression-codec", "The codec used for compression of your logs. One of: gzip, snappy, lz4").Action(c.CompressionCodec.Set).StringVar(&c.CompressionCodec.Value) + common.Format(c.CmdClause, &c.Format) + common.FormatVersion(c.CmdClause, &c.FormatVersion) + c.CmdClause.Flag("max-batch-size", "The maximum size of the log batch in bytes").Action(c.RequestMaxBytes.Set).IntVar(&c.RequestMaxBytes.Value) + c.CmdClause.Flag("new-name", "New name of the Kafka logging object").Action(c.NewName.Set).StringVar(&c.NewName.Value) + c.CmdClause.Flag("parse-log-keyvals", "Parse key-value pairs within the log format").Action(c.ParseLogKeyvals.Set).NegatableBoolVar(&c.ParseLogKeyvals.Value) + c.CmdClause.Flag("password", "SASL authentication password. Required if --auth-method is specified").Action(c.Password.Set).StringVar(&c.Password.Value) + common.Placement(c.CmdClause, &c.Placement) + c.CmdClause.Flag("required-acks", "The Number of acknowledgements a leader must receive before a write is considered successful. One of: 1 (default) One server needs to respond. 0 No servers need to respond. -1 Wait for all in-sync replicas to respond").Action(c.RequiredACKs.Set).StringVar(&c.RequiredACKs.Value) + common.ResponseCondition(c.CmdClause, &c.ResponseCondition) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.ServiceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.ServiceName.Value, + }) + common.TLSCACert(c.CmdClause, &c.TLSCACert) + common.TLSClientCert(c.CmdClause, &c.TLSClientCert) + common.TLSClientKey(c.CmdClause, &c.TLSClientKey) + common.TLSHostname(c.CmdClause, &c.TLSHostname) + c.CmdClause.Flag("topic", "The Kafka topic to send logs to").Action(c.Topic.Set).StringVar(&c.Topic.Value) + c.CmdClause.Flag("use-sasl", "Enable SASL authentication. Requires --auth-method, --username, and --password to be specified").Action(c.UseSASL.Set).BoolVar(&c.UseSASL.Value) + c.CmdClause.Flag("use-tls", "Whether to use TLS for secure logging. Can be either true or false").Action(c.UseTLS.Set).BoolVar(&c.UseTLS.Value) + c.CmdClause.Flag("username", "SASL authentication username. Required if --auth-method is specified").Action(c.User.Set).StringVar(&c.User.Value) + return &c +} + +// ConstructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *UpdateCommand) ConstructInput(serviceID string, serviceVersion int) (*fastly.UpdateKafkaInput, error) { + if c.UseSASL.WasSet && c.UseSASL.Value && (c.AuthMethod.Value == "" || c.User.Value == "" || c.Password.Value == "") { + return nil, fmt.Errorf("the --auth-method, --username, and --password flags must be present when using the --use-sasl flag") + } + + if !c.UseSASL.Value && (c.AuthMethod.Value != "" || c.User.Value != "" || c.Password.Value != "") { + return nil, fmt.Errorf("the --auth-method, --username, and --password options are only valid when the --use-sasl flag is specified") + } + + input := fastly.UpdateKafkaInput{ + ServiceID: serviceID, + ServiceVersion: serviceVersion, + Name: c.EndpointName, + } + + if c.NewName.WasSet { + input.NewName = &c.NewName.Value + } + + if c.Topic.WasSet { + input.Topic = &c.Topic.Value + } + + if c.Brokers.WasSet { + input.Brokers = &c.Brokers.Value + } + + if c.CompressionCodec.WasSet { + input.CompressionCodec = &c.CompressionCodec.Value + } + + if c.RequiredACKs.WasSet { + input.RequiredACKs = &c.RequiredACKs.Value + } + + if c.UseTLS.WasSet { + input.UseTLS = fastly.ToPointer(fastly.Compatibool(c.UseTLS.Value)) + } + + if c.TLSCACert.WasSet { + input.TLSCACert = &c.TLSCACert.Value + } + + if c.TLSClientCert.WasSet { + input.TLSClientCert = &c.TLSClientCert.Value + } + + if c.TLSClientKey.WasSet { + input.TLSClientKey = &c.TLSClientKey.Value + } + + if c.TLSHostname.WasSet { + input.TLSHostname = &c.TLSHostname.Value + } + + if c.Format.WasSet { + input.Format = fastly.ToPointer(argparser.Content(c.Format.Value)) + } + + if c.FormatVersion.WasSet { + input.FormatVersion = &c.FormatVersion.Value + } + + if c.ResponseCondition.WasSet { + input.ResponseCondition = &c.ResponseCondition.Value + } + + if c.Placement.WasSet { + input.Placement = &c.Placement.Value + } + + if c.ParseLogKeyvals.WasSet { + input.ParseLogKeyvals = fastly.ToPointer(fastly.Compatibool(c.ParseLogKeyvals.Value)) + } + + if c.RequestMaxBytes.WasSet { + input.RequestMaxBytes = &c.RequestMaxBytes.Value + } + + if c.UseSASL.WasSet && !c.UseSASL.Value { + input.AuthMethod = fastly.ToPointer("") + input.User = fastly.ToPointer("") + input.Password = fastly.ToPointer("") + } + + if c.AuthMethod.WasSet { + input.AuthMethod = &c.AuthMethod.Value + } + + if c.User.WasSet { + input.User = &c.User.Value + } + + if c.Password.WasSet { + input.Password = &c.Password.Value + } + + return &input, nil +} + +// Exec invokes the application logic for the command. +func (c *UpdateCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.AutoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.ServiceName, + ServiceVersionFlag: c.ServiceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + input, err := c.ConstructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + kafka, err := c.Globals.APIClient.UpdateKafka(input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + text.Success(out, + "Updated Kafka logging endpoint %s (service %s version %d)", + fastly.ToValue(kafka.Name), + fastly.ToValue(kafka.ServiceID), + fastly.ToValue(kafka.ServiceVersion), + ) + return nil +} diff --git a/pkg/commands/logging/kinesis/create.go b/pkg/commands/logging/kinesis/create.go new file mode 100644 index 000000000..4fc4c991f --- /dev/null +++ b/pkg/commands/logging/kinesis/create.go @@ -0,0 +1,200 @@ +package kinesis + +import ( + "fmt" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/logging/common" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/text" +) + +// CreateCommand calls the Fastly API to create an Amazon Kinesis logging endpoint. +type CreateCommand struct { + argparser.Base + Manifest manifest.Data + + // Required. + ServiceName argparser.OptionalServiceNameID + ServiceVersion argparser.OptionalServiceVersion + + // mutual exclusions + // AccessKey + SecretKey or IAMRole must be provided + AccessKey argparser.OptionalString + SecretKey argparser.OptionalString + IAMRole argparser.OptionalString + + // Optional. + AutoClone argparser.OptionalAutoClone + EndpointName argparser.OptionalString // Can't shadow argparser.Base method Name(). + Format argparser.OptionalString + FormatVersion argparser.OptionalInt + Placement argparser.OptionalString + Region argparser.OptionalString + ResponseCondition argparser.OptionalString + StreamName argparser.OptionalString +} + +// NewCreateCommand returns a usable command registered under the parent. +func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { + c := CreateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("create", "Create an Amazon Kinesis logging endpoint on a Fastly service version").Alias("add") + + // Required. + c.CmdClause.Flag("name", "The name of the Kinesis logging object. Used as a primary key for API access").Short('n').Action(c.EndpointName.Set).StringVar(&c.EndpointName.Value) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.ServiceVersion.Value, + Required: true, + }) + + // required, but mutually exclusive + c.CmdClause.Flag("access-key", "The access key associated with the target Amazon Kinesis stream").Action(c.AccessKey.Set).StringVar(&c.AccessKey.Value) + c.CmdClause.Flag("secret-key", "The secret key associated with the target Amazon Kinesis stream").Action(c.SecretKey.Set).StringVar(&c.SecretKey.Value) + c.CmdClause.Flag("iam-role", "The IAM role ARN for logging").Action(c.IAMRole.Set).StringVar(&c.IAMRole.Value) + + // Optional. + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.AutoClone.Set, + Dst: &c.AutoClone.Value, + }) + common.Format(c.CmdClause, &c.Format) + common.FormatVersion(c.CmdClause, &c.FormatVersion) + c.CmdClause.Flag("region", "The AWS region where the Kinesis stream exists").Action(c.Region.Set).StringVar(&c.Region.Value) + common.ResponseCondition(c.CmdClause, &c.ResponseCondition) + common.Placement(c.CmdClause, &c.Placement) + c.CmdClause.Flag("stream-name", "The Amazon Kinesis stream to send logs to").Action(c.StreamName.Set).StringVar(&c.StreamName.Value) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.ServiceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.ServiceName.Value, + }) + + return &c +} + +// ConstructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *CreateCommand) ConstructInput(serviceID string, serviceVersion int) (*fastly.CreateKinesisInput, error) { + var input fastly.CreateKinesisInput + + input.ServiceID = serviceID + if c.EndpointName.WasSet { + input.Name = &c.EndpointName.Value + } + if c.StreamName.WasSet { + input.StreamName = &c.StreamName.Value + } + if c.Region.WasSet { + input.Region = &c.Region.Value + } + input.ServiceVersion = serviceVersion + + // The following block checks for invalid permutations of the ways in + // which the AccessKey + SecretKey and IAMRole flags can be + // provided. This is necessary because either the AccessKey and + // SecretKey or the IAMRole is required, but they are mutually + // exclusive. The kingpin library lacks a way to express this constraint + // via the flag specification API so we enforce it manually here. + switch { + case !c.AccessKey.WasSet && !c.SecretKey.WasSet && !c.IAMRole.WasSet: + return nil, fmt.Errorf("error parsing arguments: the --access-key and --secret-key flags or the --iam-role flag must be provided") + case (c.AccessKey.WasSet || c.SecretKey.WasSet) && c.IAMRole.WasSet: + // Enforce mutual exclusion + return nil, fmt.Errorf("error parsing arguments: the --access-key and --secret-key flags are mutually exclusive with the --iam-role flag") + case c.AccessKey.WasSet && !c.SecretKey.WasSet: + return nil, fmt.Errorf("error parsing arguments: required flag --secret-key not provided") + case !c.AccessKey.WasSet && c.SecretKey.WasSet: + return nil, fmt.Errorf("error parsing arguments: required flag --access-key not provided") + } + + if c.AccessKey.WasSet { + input.AccessKey = &c.AccessKey.Value + } + + if c.SecretKey.WasSet { + input.SecretKey = &c.SecretKey.Value + } + + if c.IAMRole.WasSet { + input.IAMRole = &c.IAMRole.Value + } + + if c.Format.WasSet { + input.Format = fastly.ToPointer(argparser.Content(c.Format.Value)) + } + + if c.FormatVersion.WasSet { + input.FormatVersion = &c.FormatVersion.Value + } + + if c.ResponseCondition.WasSet { + input.ResponseCondition = &c.ResponseCondition.Value + } + + if c.Placement.WasSet { + input.Placement = &c.Placement.Value + } + + return &input, nil +} + +// Exec invokes the application logic for the command. +func (c *CreateCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.AutoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.ServiceName, + ServiceVersionFlag: c.ServiceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + input, err := c.ConstructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + d, err := c.Globals.APIClient.CreateKinesis(input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + text.Success(out, + "Created Kinesis logging endpoint %s (service %s version %d)", + fastly.ToValue(d.Name), + fastly.ToValue(d.ServiceID), + fastly.ToValue(d.ServiceVersion), + ) + return nil +} diff --git a/pkg/commands/logging/kinesis/delete.go b/pkg/commands/logging/kinesis/delete.go new file mode 100644 index 000000000..28769562d --- /dev/null +++ b/pkg/commands/logging/kinesis/delete.go @@ -0,0 +1,95 @@ +package kinesis + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// DeleteCommand calls the Fastly API to delete an Amazon Kinesis logging endpoint. +type DeleteCommand struct { + argparser.Base + Input fastly.DeleteKinesisInput + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion + autoClone argparser.OptionalAutoClone +} + +// NewDeleteCommand returns a usable command registered under the parent. +func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { + c := DeleteCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("delete", "Delete a Kinesis logging endpoint on a Fastly service version").Alias("remove") + + // Required. + c.CmdClause.Flag("name", "The name of the Kinesis logging object").Short('n').Required().StringVar(&c.Input.Name) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.autoClone.Set, + Dst: &c.autoClone.Value, + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + + return &c +} + +// Exec invokes the application logic for the command. +func (c *DeleteCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.autoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + c.Input.ServiceID = serviceID + c.Input.ServiceVersion = fastly.ToValue(serviceVersion.Number) + + if err := c.Globals.APIClient.DeleteKinesis(&c.Input); err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + text.Success(out, "Deleted Kinesis logging endpoint %s (service %s version %d)", c.Input.Name, c.Input.ServiceID, c.Input.ServiceVersion) + return nil +} diff --git a/pkg/commands/logging/kinesis/describe.go b/pkg/commands/logging/kinesis/describe.go new file mode 100644 index 000000000..20c7bf484 --- /dev/null +++ b/pkg/commands/logging/kinesis/describe.go @@ -0,0 +1,118 @@ +package kinesis + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// DescribeCommand calls the Fastly API to describe an Amazon Kinesis logging endpoint. +type DescribeCommand struct { + argparser.Base + argparser.JSONOutput + + Input fastly.GetKinesisInput + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion +} + +// NewDescribeCommand returns a usable command registered under the parent. +func NewDescribeCommand(parent argparser.Registerer, g *global.Data) *DescribeCommand { + c := DescribeCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("describe", "Show detailed information about a Kinesis logging endpoint on a Fastly service version").Alias("get") + + // Required. + c.CmdClause.Flag("name", "The name of the Kinesis logging object").Short('n').Required().StringVar(&c.Input.Name) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + return &c +} + +// Exec invokes the application logic for the command. +func (c *DescribeCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fsterr.ServiceVersion(serviceVersion), + }) + return err + } + + c.Input.ServiceID = serviceID + c.Input.ServiceVersion = fastly.ToValue(serviceVersion.Number) + + o, err := c.Globals.APIClient.GetKinesis(&c.Input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + lines := text.Lines{ + "Format version": fastly.ToValue(o.FormatVersion), + "Format": fastly.ToValue(o.Format), + "Name": fastly.ToValue(o.Name), + "Placement": fastly.ToValue(o.Placement), + "Region": fastly.ToValue(o.Region), + "Response condition": fastly.ToValue(o.ResponseCondition), + "Stream name": fastly.ToValue(o.StreamName), + "Version": fastly.ToValue(o.ServiceVersion), + } + + if o.AccessKey != nil || o.SecretKey != nil { + lines["Access key"] = fastly.ToValue(o.AccessKey) + lines["Secret key"] = fastly.ToValue(o.SecretKey) + } + if o.IAMRole != nil { + lines["IAM role"] = fastly.ToValue(o.IAMRole) + } + if !c.Globals.Verbose() { + lines["Service ID"] = fastly.ToValue(o.ServiceID) + } + text.PrintLines(out, lines) + + return nil +} diff --git a/pkg/logging/kinesis/doc.go b/pkg/commands/logging/kinesis/doc.go similarity index 100% rename from pkg/logging/kinesis/doc.go rename to pkg/commands/logging/kinesis/doc.go diff --git a/pkg/commands/logging/kinesis/kinesis_integration_test.go b/pkg/commands/logging/kinesis/kinesis_integration_test.go new file mode 100644 index 000000000..affe2fbc2 --- /dev/null +++ b/pkg/commands/logging/kinesis/kinesis_integration_test.go @@ -0,0 +1,463 @@ +package kinesis_test + +import ( + "bytes" + "errors" + "io" + "strings" + "testing" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/app" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" +) + +func TestKinesisCreate(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging kinesis create --service-id 123 --version 1 --name log --stream-name log --region us-east-1 --secret-key bar --iam-role arn:aws:iam::123456789012:role/KinesisAccess --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + }, + wantError: "error parsing arguments: the --access-key and --secret-key flags are mutually exclusive with the --iam-role flag", + }, + { + args: args("logging kinesis create --service-id 123 --version 1 --name log --stream-name log --region us-east-1 --access-key foo --iam-role arn:aws:iam::123456789012:role/KinesisAccess --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + }, + wantError: "error parsing arguments: the --access-key and --secret-key flags are mutually exclusive with the --iam-role flag", + }, + { + args: args("logging kinesis create --service-id 123 --version 1 --name log --stream-name log --region us-east-1 --access-key foo --secret-key bar --iam-role arn:aws:iam::123456789012:role/KinesisAccess --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + }, + wantError: "error parsing arguments: the --access-key and --secret-key flags are mutually exclusive with the --iam-role flag", + }, + { + args: args("logging kinesis create --service-id 123 --version 1 --name log --stream-name log --access-key foo --secret-key bar --region us-east-1 --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + CreateKinesisFn: createKinesisOK, + }, + wantOutput: "Created Kinesis logging endpoint log (service 123 version 4)", + }, + { + args: args("logging kinesis create --service-id 123 --version 1 --name log --stream-name log --access-key foo --secret-key bar --region us-east-1 --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + CreateKinesisFn: createKinesisError, + }, + wantError: errTest.Error(), + }, + { + args: args("logging kinesis create --service-id 123 --version 1 --name log2 --stream-name log --region us-east-1 --iam-role arn:aws:iam::123456789012:role/KinesisAccess --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + CreateKinesisFn: createKinesisOK, + }, + wantOutput: "Created Kinesis logging endpoint log2 (service 123 version 4)", + }, + { + args: args("logging kinesis create --service-id 123 --version 1 --name log2 --stream-name log --region us-east-1 --iam-role arn:aws:iam::123456789012:role/KinesisAccess --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + CreateKinesisFn: createKinesisError, + }, + wantError: errTest.Error(), + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertStringContains(t, stdout.String(), testcase.wantOutput) + }) + } +} + +func TestKinesisList(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging kinesis list --service-id 123 --version 1"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListKinesisFn: listKinesesOK, + }, + wantOutput: listKinesesShortOutput, + }, + { + args: args("logging kinesis list --service-id 123 --version 1 --verbose"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListKinesisFn: listKinesesOK, + }, + wantOutput: listKinesesVerboseOutput, + }, + { + args: args("logging kinesis list --service-id 123 --version 1 -v"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListKinesisFn: listKinesesOK, + }, + wantOutput: listKinesesVerboseOutput, + }, + { + args: args("logging kinesis --verbose list --service-id 123 --version 1"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListKinesisFn: listKinesesOK, + }, + wantOutput: listKinesesVerboseOutput, + }, + { + args: args("logging -v kinesis list --service-id 123 --version 1"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListKinesisFn: listKinesesOK, + }, + wantOutput: listKinesesVerboseOutput, + }, + { + args: args("logging kinesis list --service-id 123 --version 1"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListKinesisFn: listKinesesError, + }, + wantError: errTest.Error(), + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertString(t, testcase.wantOutput, stdout.String()) + }) + } +} + +func TestKinesisDescribe(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging kinesis describe --service-id 123 --version 1"), + wantError: "error parsing arguments: required flag --name not provided", + }, + { + args: args("logging kinesis describe --service-id 123 --version 1 --name logs"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + GetKinesisFn: getKinesisError, + }, + wantError: errTest.Error(), + }, + { + args: args("logging kinesis describe --service-id 123 --version 1 --name logs"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + GetKinesisFn: getKinesisOK, + }, + wantOutput: describeKinesisOutput, + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertString(t, testcase.wantOutput, stdout.String()) + }) + } +} + +func TestKinesisUpdate(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging kinesis update --service-id 123 --version 1 --new-name log"), + wantError: "error parsing arguments: required flag --name not provided", + }, + { + args: args("logging kinesis update --service-id 123 --version 1 --name logs --new-name log --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + UpdateKinesisFn: updateKinesisError, + }, + wantError: errTest.Error(), + }, + { + args: args("logging kinesis update --service-id 123 --version 1 --name logs --new-name log --region us-west-1 --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + UpdateKinesisFn: updateKinesisOK, + }, + wantOutput: "Updated Kinesis logging endpoint log (service 123 version 4)", + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertStringContains(t, stdout.String(), testcase.wantOutput) + }) + } +} + +func TestKinesisDelete(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging kinesis delete --service-id 123 --version 1"), + wantError: "error parsing arguments: required flag --name not provided", + }, + { + args: args("logging kinesis delete --service-id 123 --version 1 --name logs --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + DeleteKinesisFn: deleteKinesisError, + }, + wantError: errTest.Error(), + }, + { + args: args("logging kinesis delete --service-id 123 --version 1 --name logs --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + DeleteKinesisFn: deleteKinesisOK, + }, + wantOutput: "Deleted Kinesis logging endpoint logs (service 123 version 4)", + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertStringContains(t, stdout.String(), testcase.wantOutput) + }) + } +} + +var errTest = errors.New("fixture error") + +func createKinesisOK(i *fastly.CreateKinesisInput) (*fastly.Kinesis, error) { + return &fastly.Kinesis{ + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: i.Name, + }, nil +} + +func createKinesisError(_ *fastly.CreateKinesisInput) (*fastly.Kinesis, error) { + return nil, errTest +} + +func listKinesesOK(i *fastly.ListKinesisInput) ([]*fastly.Kinesis, error) { + return []*fastly.Kinesis{ + { + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("logs"), + StreamName: fastly.ToPointer("my-logs"), + AccessKey: fastly.ToPointer("1234"), + SecretKey: fastly.ToPointer("-----BEGIN RSA PRIVATE KEY-----MIIEogIBAAKCA"), + Region: fastly.ToPointer("us-east-1"), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + FormatVersion: fastly.ToPointer(2), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + Placement: fastly.ToPointer("none"), + }, + { + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("analytics"), + StreamName: fastly.ToPointer("analytics"), + AccessKey: fastly.ToPointer("1234"), + SecretKey: fastly.ToPointer("-----BEGIN RSA PRIVATE KEY-----MIIEogIBAAKCA"), + Region: fastly.ToPointer("us-east-1"), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + FormatVersion: fastly.ToPointer(2), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + Placement: fastly.ToPointer("none"), + }, + }, nil +} + +func listKinesesError(_ *fastly.ListKinesisInput) ([]*fastly.Kinesis, error) { + return nil, errTest +} + +var listKinesesShortOutput = strings.TrimSpace(` +SERVICE VERSION NAME +123 1 logs +123 1 analytics +`) + "\n" + +var listKinesesVerboseOutput = strings.TrimSpace(` +Fastly API endpoint: https://api.fastly.com +Fastly API token provided via config file (profile: user) + +Service ID (via --service-id): 123 + +Version: 1 + Kinesis 1/2 + Service ID: 123 + Version: 1 + Name: logs + Stream name: my-logs + Region: us-east-1 + Access key: 1234 + Secret key: -----BEGIN RSA PRIVATE KEY-----MIIEogIBAAKCA + Format: %h %l %u %t "%r" %>s %b + Format version: 2 + Response condition: Prevent default logging + Placement: none + Kinesis 2/2 + Service ID: 123 + Version: 1 + Name: analytics + Stream name: analytics + Region: us-east-1 + Access key: 1234 + Secret key: -----BEGIN RSA PRIVATE KEY-----MIIEogIBAAKCA + Format: %h %l %u %t "%r" %>s %b + Format version: 2 + Response condition: Prevent default logging + Placement: none +`) + "\n\n" + +func getKinesisOK(i *fastly.GetKinesisInput) (*fastly.Kinesis, error) { + return &fastly.Kinesis{ + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("logs"), + StreamName: fastly.ToPointer("my-logs"), + AccessKey: fastly.ToPointer("1234"), + SecretKey: fastly.ToPointer("-----BEGIN RSA PRIVATE KEY-----MIIEogIBAAKCA"), + Region: fastly.ToPointer("us-east-1"), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + FormatVersion: fastly.ToPointer(2), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + Placement: fastly.ToPointer("none"), + }, nil +} + +func getKinesisError(_ *fastly.GetKinesisInput) (*fastly.Kinesis, error) { + return nil, errTest +} + +var describeKinesisOutput = "\n" + strings.TrimSpace(` +Access key: 1234 +Format: %h %l %u %t "%r" %>s %b +Format version: 2 +Name: logs +Placement: none +Region: us-east-1 +Response condition: Prevent default logging +Secret key: -----BEGIN RSA PRIVATE KEY-----MIIEogIBAAKCA +Service ID: 123 +Stream name: my-logs +Version: 1 +`) + "\n" + +func updateKinesisOK(i *fastly.UpdateKinesisInput) (*fastly.Kinesis, error) { + return &fastly.Kinesis{ + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("log"), + StreamName: fastly.ToPointer("my-logs"), + AccessKey: fastly.ToPointer("1234"), + SecretKey: fastly.ToPointer("-----BEGIN RSA PRIVATE KEY-----MIIEogIBAAKCA"), + Region: fastly.ToPointer("us-west-1"), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + FormatVersion: fastly.ToPointer(2), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + Placement: fastly.ToPointer("none"), + }, nil +} + +func updateKinesisError(_ *fastly.UpdateKinesisInput) (*fastly.Kinesis, error) { + return nil, errTest +} + +func deleteKinesisOK(_ *fastly.DeleteKinesisInput) error { + return nil +} + +func deleteKinesisError(_ *fastly.DeleteKinesisInput) error { + return errTest +} diff --git a/pkg/commands/logging/kinesis/kinesis_test.go b/pkg/commands/logging/kinesis/kinesis_test.go new file mode 100644 index 000000000..cd11b1c2e --- /dev/null +++ b/pkg/commands/logging/kinesis/kinesis_test.go @@ -0,0 +1,408 @@ +package kinesis_test + +import ( + "bytes" + "testing" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/logging/kinesis" + "github.com/fastly/cli/pkg/config" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" +) + +func TestCreateKinesisInput(t *testing.T) { + for _, testcase := range []struct { + name string + cmd *kinesis.CreateCommand + want *fastly.CreateKinesisInput + wantError string + }{ + { + name: "required values set flag serviceID", + cmd: createCommandRequired(), + want: &fastly.CreateKinesisInput{ + ServiceID: "123", + ServiceVersion: 4, + Name: fastly.ToPointer("log"), + StreamName: fastly.ToPointer("stream"), + Region: fastly.ToPointer("us-east-1"), + AccessKey: fastly.ToPointer("access"), + SecretKey: fastly.ToPointer("secret"), + }, + }, + { + name: "required values set flag serviceID using IAM role", + cmd: createCommandRequiredIAMRole(), + want: &fastly.CreateKinesisInput{ + ServiceID: "123", + ServiceVersion: 4, + Name: fastly.ToPointer("log"), + Region: fastly.ToPointer("us-east-1"), + StreamName: fastly.ToPointer("stream"), + IAMRole: fastly.ToPointer("arn:aws:iam::123456789012:role/KinesisAccess"), + }, + }, + { + name: "all values set flag serviceID", + cmd: createCommandAll(), + want: &fastly.CreateKinesisInput{ + ServiceID: "123", + ServiceVersion: 4, + Name: fastly.ToPointer("logs"), + StreamName: fastly.ToPointer("stream"), + Region: fastly.ToPointer("us-east-1"), + AccessKey: fastly.ToPointer("access"), + SecretKey: fastly.ToPointer("secret"), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + FormatVersion: fastly.ToPointer(2), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + Placement: fastly.ToPointer("none"), + }, + }, + { + name: "error missing serviceID", + cmd: createCommandMissingServiceID(), + want: nil, + wantError: errors.ErrNoServiceID.Error(), + }, + } { + t.Run(testcase.name, func(t *testing.T) { + var bs []byte + out := bytes.NewBuffer(bs) + verboseMode := true + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + AutoCloneFlag: testcase.cmd.AutoClone, + APIClient: testcase.cmd.Globals.APIClient, + Manifest: testcase.cmd.Manifest, + Out: out, + ServiceVersionFlag: testcase.cmd.ServiceVersion, + VerboseMode: verboseMode, + }) + + switch { + case err != nil && testcase.wantError == "": + t.Fatalf("unexpected error getting service details: %v", err) + return + case err != nil && testcase.wantError != "": + testutil.AssertErrorContains(t, err, testcase.wantError) + return + case err == nil && testcase.wantError != "": + t.Fatalf("expected error, have nil (service details: %s, %d)", serviceID, serviceVersion.Number) + case err == nil && testcase.wantError == "": + have, err := testcase.cmd.ConstructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertEqual(t, testcase.want, have) + } + }) + } +} + +func TestUpdateKinesisInput(t *testing.T) { + scenarios := []struct { + name string + cmd *kinesis.UpdateCommand + api mock.API + want *fastly.UpdateKinesisInput + wantError string + }{ + { + name: "no updates", + cmd: updateCommandNoUpdates(), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + GetKinesisFn: getKinesisOK, + }, + want: &fastly.UpdateKinesisInput{ + ServiceID: "123", + ServiceVersion: 4, + Name: "log", + }, + }, + { + name: "all values set flag serviceID", + cmd: updateCommandAll(), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + GetKinesisFn: getKinesisOK, + }, + want: &fastly.UpdateKinesisInput{ + ServiceID: "123", + ServiceVersion: 4, + Name: "log", + NewName: fastly.ToPointer("new1"), + StreamName: fastly.ToPointer("new2"), + AccessKey: fastly.ToPointer("new3"), + SecretKey: fastly.ToPointer("new4"), + IAMRole: fastly.ToPointer(""), + Region: fastly.ToPointer("new5"), + Format: fastly.ToPointer("new7"), + FormatVersion: fastly.ToPointer(3), + ResponseCondition: fastly.ToPointer("new9"), + Placement: fastly.ToPointer("new11"), + }, + }, + { + name: "error missing serviceID", + cmd: updateCommandMissingServiceID(), + want: nil, + wantError: errors.ErrNoServiceID.Error(), + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(testcase.name, func(t *testing.T) { + testcase.cmd.Globals.APIClient = testcase.api + + var bs []byte + out := bytes.NewBuffer(bs) + verboseMode := true + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + AutoCloneFlag: testcase.cmd.AutoClone, + APIClient: testcase.api, + Manifest: testcase.cmd.Manifest, + Out: out, + ServiceVersionFlag: testcase.cmd.ServiceVersion, + VerboseMode: verboseMode, + }) + + switch { + case err != nil && testcase.wantError == "": + t.Fatalf("unexpected error getting service details: %v", err) + return + case err != nil && testcase.wantError != "": + testutil.AssertErrorContains(t, err, testcase.wantError) + return + case err == nil && testcase.wantError != "": + t.Fatalf("expected error, have nil (service details: %s, %d)", serviceID, serviceVersion.Number) + case err == nil && testcase.wantError == "": + have, err := testcase.cmd.ConstructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertEqual(t, testcase.want, have) + } + }) + } +} + +func createCommandRequired() *kinesis.CreateCommand { + var b bytes.Buffer + + g := global.Data{ + Config: config.File{}, + Env: config.Environment{}, + Output: &b, + } + g.APIClient, _ = mock.APIClient(mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + })("token", "endpoint", false) + + return &kinesis.CreateCommand{ + Base: argparser.Base{ + Globals: &g, + }, + Manifest: manifest.Data{ + Flag: manifest.Flag{ + ServiceID: "123", + }, + }, + ServiceVersion: argparser.OptionalServiceVersion{ + OptionalString: argparser.OptionalString{Value: "1"}, + }, + AutoClone: argparser.OptionalAutoClone{ + OptionalBool: argparser.OptionalBool{ + Optional: argparser.Optional{ + WasSet: true, + }, + Value: true, + }, + }, + EndpointName: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "log"}, + Region: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "us-east-1"}, + StreamName: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "stream"}, + AccessKey: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "access"}, + SecretKey: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "secret"}, + } +} + +func createCommandRequiredIAMRole() *kinesis.CreateCommand { + var b bytes.Buffer + + g := global.Data{ + Config: config.File{}, + Env: config.Environment{}, + Output: &b, + } + g.APIClient, _ = mock.APIClient(mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + })("token", "endpoint", false) + + return &kinesis.CreateCommand{ + Base: argparser.Base{ + Globals: &g, + }, + Manifest: manifest.Data{ + Flag: manifest.Flag{ + ServiceID: "123", + }, + }, + ServiceVersion: argparser.OptionalServiceVersion{ + OptionalString: argparser.OptionalString{Value: "1"}, + }, + EndpointName: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "log"}, + Region: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "us-east-1"}, + StreamName: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "stream"}, + AutoClone: argparser.OptionalAutoClone{ + OptionalBool: argparser.OptionalBool{ + Optional: argparser.Optional{ + WasSet: true, + }, + Value: true, + }, + }, + IAMRole: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "arn:aws:iam::123456789012:role/KinesisAccess"}, + } +} + +func createCommandAll() *kinesis.CreateCommand { + var b bytes.Buffer + + g := global.Data{ + Config: config.File{}, + Env: config.Environment{}, + Output: &b, + } + g.APIClient, _ = mock.APIClient(mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + })("token", "endpoint", false) + + return &kinesis.CreateCommand{ + Base: argparser.Base{ + Globals: &g, + }, + Manifest: manifest.Data{ + Flag: manifest.Flag{ + ServiceID: "123", + }, + }, + ServiceVersion: argparser.OptionalServiceVersion{ + OptionalString: argparser.OptionalString{Value: "1"}, + }, + AutoClone: argparser.OptionalAutoClone{ + OptionalBool: argparser.OptionalBool{ + Optional: argparser.Optional{ + WasSet: true, + }, + Value: true, + }, + }, + EndpointName: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "logs"}, + StreamName: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "stream"}, + Region: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "us-east-1"}, + AccessKey: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "access"}, + SecretKey: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "secret"}, + Format: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: `%h %l %u %t "%r" %>s %b`}, + FormatVersion: argparser.OptionalInt{Optional: argparser.Optional{WasSet: true}, Value: 2}, + ResponseCondition: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "Prevent default logging"}, + Placement: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "none"}, + } +} + +func createCommandMissingServiceID() *kinesis.CreateCommand { + res := createCommandAll() + res.Manifest = manifest.Data{} + return res +} + +func updateCommandNoUpdates() *kinesis.UpdateCommand { + var b bytes.Buffer + + g := global.Data{ + Config: config.File{}, + Env: config.Environment{}, + Output: &b, + } + + return &kinesis.UpdateCommand{ + Base: argparser.Base{ + Globals: &g, + }, + Manifest: manifest.Data{ + Flag: manifest.Flag{ + ServiceID: "123", + }, + }, + EndpointName: "log", + ServiceVersion: argparser.OptionalServiceVersion{ + OptionalString: argparser.OptionalString{Value: "1"}, + }, + AutoClone: argparser.OptionalAutoClone{ + OptionalBool: argparser.OptionalBool{ + Optional: argparser.Optional{ + WasSet: true, + }, + Value: true, + }, + }, + } +} + +func updateCommandAll() *kinesis.UpdateCommand { + var b bytes.Buffer + + g := global.Data{ + Config: config.File{}, + Env: config.Environment{}, + Output: &b, + } + + return &kinesis.UpdateCommand{ + Base: argparser.Base{ + Globals: &g, + }, + Manifest: manifest.Data{ + Flag: manifest.Flag{ + ServiceID: "123", + }, + }, + EndpointName: "log", + ServiceVersion: argparser.OptionalServiceVersion{ + OptionalString: argparser.OptionalString{Value: "1"}, + }, + AutoClone: argparser.OptionalAutoClone{ + OptionalBool: argparser.OptionalBool{ + Optional: argparser.Optional{ + WasSet: true, + }, + Value: true, + }, + }, + NewName: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new1"}, + StreamName: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new2"}, + AccessKey: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new3"}, + SecretKey: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new4"}, + IAMRole: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: ""}, + Region: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new5"}, + Format: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new7"}, + FormatVersion: argparser.OptionalInt{Optional: argparser.Optional{WasSet: true}, Value: 3}, + ResponseCondition: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new9"}, + Placement: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new11"}, + } +} + +func updateCommandMissingServiceID() *kinesis.UpdateCommand { + res := updateCommandAll() + res.Manifest = manifest.Data{} + return res +} diff --git a/pkg/commands/logging/kinesis/list.go b/pkg/commands/logging/kinesis/list.go new file mode 100644 index 000000000..9ab01d964 --- /dev/null +++ b/pkg/commands/logging/kinesis/list.go @@ -0,0 +1,131 @@ +package kinesis + +import ( + "fmt" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// ListCommand calls the Fastly API to list Amazon Kinesis logging endpoints. +type ListCommand struct { + argparser.Base + argparser.JSONOutput + + Input fastly.ListKinesisInput + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion +} + +// NewListCommand returns a usable command registered under the parent. +func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { + c := ListCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("list", "List Kinesis endpoints on a Fastly service version") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + return &c +} + +// Exec invokes the application logic for the command. +func (c *ListCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fsterr.ServiceVersion(serviceVersion), + }) + return err + } + + c.Input.ServiceID = serviceID + c.Input.ServiceVersion = fastly.ToValue(serviceVersion.Number) + + o, err := c.Globals.APIClient.ListKinesis(&c.Input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + if !c.Globals.Verbose() { + tw := text.NewTable(out) + tw.AddHeader("SERVICE", "VERSION", "NAME") + for _, kinesis := range o { + tw.AddLine( + fastly.ToValue(kinesis.ServiceID), + fastly.ToValue(kinesis.ServiceVersion), + fastly.ToValue(kinesis.Name), + ) + } + tw.Print() + return nil + } + + fmt.Fprintf(out, "Version: %d\n", c.Input.ServiceVersion) + for i, kinesis := range o { + fmt.Fprintf(out, "\tKinesis %d/%d\n", i+1, len(o)) + fmt.Fprintf(out, "\t\tService ID: %s\n", fastly.ToValue(kinesis.ServiceID)) + fmt.Fprintf(out, "\t\tVersion: %d\n", fastly.ToValue(kinesis.ServiceVersion)) + fmt.Fprintf(out, "\t\tName: %s\n", fastly.ToValue(kinesis.Name)) + fmt.Fprintf(out, "\t\tStream name: %s\n", fastly.ToValue(kinesis.StreamName)) + fmt.Fprintf(out, "\t\tRegion: %s\n", fastly.ToValue(kinesis.Region)) + if kinesis.AccessKey != nil || kinesis.SecretKey != nil { + fmt.Fprintf(out, "\t\tAccess key: %s\n", fastly.ToValue(kinesis.AccessKey)) + fmt.Fprintf(out, "\t\tSecret key: %s\n", fastly.ToValue(kinesis.SecretKey)) + } + if kinesis.IAMRole != nil { + fmt.Fprintf(out, "\t\tIAM role: %s\n", fastly.ToValue(kinesis.IAMRole)) + } + fmt.Fprintf(out, "\t\tFormat: %s\n", fastly.ToValue(kinesis.Format)) + fmt.Fprintf(out, "\t\tFormat version: %d\n", fastly.ToValue(kinesis.FormatVersion)) + fmt.Fprintf(out, "\t\tResponse condition: %s\n", fastly.ToValue(kinesis.ResponseCondition)) + fmt.Fprintf(out, "\t\tPlacement: %s\n", fastly.ToValue(kinesis.Placement)) + } + fmt.Fprintln(out) + + return nil +} diff --git a/pkg/commands/logging/kinesis/root.go b/pkg/commands/logging/kinesis/root.go new file mode 100644 index 000000000..0b3c0f74f --- /dev/null +++ b/pkg/commands/logging/kinesis/root.go @@ -0,0 +1,31 @@ +package kinesis + +import ( + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" +) + +// RootCommand is the parent command for all subcommands in this package. +// It should be installed under the primary root command. +type RootCommand struct { + argparser.Base + // no flags +} + +// CommandName is the string to be used to invoke this command. +const CommandName = "kinesis" + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent argparser.Registerer, g *global.Data) *RootCommand { + var c RootCommand + c.Globals = g + c.CmdClause = parent.Command(CommandName, "Manipulate a Kinesis logging endpoint for a specific Fastly service version") + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { + panic("unreachable") +} diff --git a/pkg/commands/logging/kinesis/update.go b/pkg/commands/logging/kinesis/update.go new file mode 100644 index 000000000..24ff5d5c4 --- /dev/null +++ b/pkg/commands/logging/kinesis/update.go @@ -0,0 +1,181 @@ +package kinesis + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/logging/common" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/text" +) + +// UpdateCommand calls the Fastly API to update an Amazon Kinesis logging endpoint. +type UpdateCommand struct { + argparser.Base + Manifest manifest.Data + + // Required. + EndpointName string // Can't shadow argparser.Base method Name(). + ServiceName argparser.OptionalServiceNameID + ServiceVersion argparser.OptionalServiceVersion + + // Optional. + AutoClone argparser.OptionalAutoClone + NewName argparser.OptionalString + StreamName argparser.OptionalString + AccessKey argparser.OptionalString + SecretKey argparser.OptionalString + IAMRole argparser.OptionalString + Region argparser.OptionalString + Format argparser.OptionalString + FormatVersion argparser.OptionalInt + ResponseCondition argparser.OptionalString + Placement argparser.OptionalString +} + +// NewUpdateCommand returns a usable command registered under the parent. +func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { + c := UpdateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("update", "Update a Kinesis logging endpoint on a Fastly service version") + + // Required. + c.CmdClause.Flag("name", "The name of the Kinesis logging object").Short('n').Required().StringVar(&c.EndpointName) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.ServiceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.AutoClone.Set, + Dst: &c.AutoClone.Value, + }) + c.CmdClause.Flag("access-key", "Your Kinesis account access key").Action(c.AccessKey.Set).StringVar(&c.AccessKey.Value) + common.Format(c.CmdClause, &c.Format) + common.FormatVersion(c.CmdClause, &c.FormatVersion) + c.CmdClause.Flag("iam-role", "The IAM role ARN for logging").Action(c.IAMRole.Set).StringVar(&c.IAMRole.Value) + c.CmdClause.Flag("new-name", "New name of the Kinesis logging object").Action(c.NewName.Set).StringVar(&c.NewName.Value) + common.Placement(c.CmdClause, &c.Placement) + c.CmdClause.Flag("region", "The AWS region where the Kinesis stream exists").Action(c.Region.Set).StringVar(&c.Region.Value) + common.ResponseCondition(c.CmdClause, &c.ResponseCondition) + c.CmdClause.Flag("secret-key", "Your Kinesis account secret key").Action(c.SecretKey.Set).StringVar(&c.SecretKey.Value) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.ServiceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.ServiceName.Value, + }) + c.CmdClause.Flag("stream-name", "Your Kinesis stream name").Action(c.StreamName.Set).StringVar(&c.StreamName.Value) + return &c +} + +// ConstructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *UpdateCommand) ConstructInput(serviceID string, serviceVersion int) (*fastly.UpdateKinesisInput, error) { + input := fastly.UpdateKinesisInput{ + ServiceID: serviceID, + ServiceVersion: serviceVersion, + Name: c.EndpointName, + } + + if c.NewName.WasSet { + input.NewName = &c.NewName.Value + } + + if c.StreamName.WasSet { + input.StreamName = &c.StreamName.Value + } + + if c.AccessKey.WasSet { + input.AccessKey = &c.AccessKey.Value + } + + if c.SecretKey.WasSet { + input.SecretKey = &c.SecretKey.Value + } + + if c.IAMRole.WasSet { + input.IAMRole = &c.IAMRole.Value + } + + if c.Region.WasSet { + input.Region = &c.Region.Value + } + + if c.Format.WasSet { + input.Format = fastly.ToPointer(argparser.Content(c.Format.Value)) + } + + if c.FormatVersion.WasSet { + input.FormatVersion = &c.FormatVersion.Value + } + + if c.ResponseCondition.WasSet { + input.ResponseCondition = &c.ResponseCondition.Value + } + + if c.Placement.WasSet { + input.Placement = &c.Placement.Value + } + + return &input, nil +} + +// Exec invokes the application logic for the command. +func (c *UpdateCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.AutoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.ServiceName, + ServiceVersionFlag: c.ServiceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + input, err := c.ConstructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + kinesis, err := c.Globals.APIClient.UpdateKinesis(input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + text.Success(out, + "Updated Kinesis logging endpoint %s (service %s version %d)", + fastly.ToValue(kinesis.Name), + fastly.ToValue(kinesis.ServiceID), + fastly.ToValue(kinesis.ServiceVersion), + ) + return nil +} diff --git a/pkg/commands/logging/loggly/create.go b/pkg/commands/logging/loggly/create.go new file mode 100644 index 000000000..1b8a0a3b4 --- /dev/null +++ b/pkg/commands/logging/loggly/create.go @@ -0,0 +1,152 @@ +package loggly + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/logging/common" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/text" +) + +// CreateCommand calls the Fastly API to create a Loggly logging endpoint. +type CreateCommand struct { + argparser.Base + Manifest manifest.Data + + // Required. + ServiceName argparser.OptionalServiceNameID + ServiceVersion argparser.OptionalServiceVersion + + // Optional. + AutoClone argparser.OptionalAutoClone + EndpointName argparser.OptionalString // Can't shadow argparser.Base method Name(). + Format argparser.OptionalString + FormatVersion argparser.OptionalInt + Placement argparser.OptionalString + ResponseCondition argparser.OptionalString + Token argparser.OptionalString +} + +// NewCreateCommand returns a usable command registered under the parent. +func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { + c := CreateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("create", "Create a Loggly logging endpoint on a Fastly service version").Alias("add") + + // Required. + c.CmdClause.Flag("name", "The name of the Loggly logging object. Used as a primary key for API access").Short('n').Action(c.EndpointName.Set).StringVar(&c.EndpointName.Value) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.ServiceVersion.Value, + Required: true, + }) + + // Optional. + c.CmdClause.Flag("auth-token", "The token to use for authentication (https://www.loggly.com/docs/customer-token-authentication-token/)").Action(c.Token.Set).StringVar(&c.Token.Value) + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.AutoClone.Set, + Dst: &c.AutoClone.Value, + }) + common.Format(c.CmdClause, &c.Format) + common.FormatVersion(c.CmdClause, &c.FormatVersion) + common.ResponseCondition(c.CmdClause, &c.ResponseCondition) + common.Placement(c.CmdClause, &c.Placement) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.ServiceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.ServiceName.Value, + }) + return &c +} + +// ConstructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *CreateCommand) ConstructInput(serviceID string, serviceVersion int) (*fastly.CreateLogglyInput, error) { + var input fastly.CreateLogglyInput + + input.ServiceID = serviceID + input.ServiceVersion = serviceVersion + if c.EndpointName.WasSet { + input.Name = &c.EndpointName.Value + } + if c.Token.WasSet { + input.Token = &c.Token.Value + } + + if c.Format.WasSet { + input.Format = fastly.ToPointer(argparser.Content(c.Format.Value)) + } + + if c.FormatVersion.WasSet { + input.FormatVersion = &c.FormatVersion.Value + } + + if c.ResponseCondition.WasSet { + input.ResponseCondition = &c.ResponseCondition.Value + } + + if c.Placement.WasSet { + input.Placement = &c.Placement.Value + } + + return &input, nil +} + +// Exec invokes the application logic for the command. +func (c *CreateCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.AutoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.ServiceName, + ServiceVersionFlag: c.ServiceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + input, err := c.ConstructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + d, err := c.Globals.APIClient.CreateLoggly(input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + text.Success(out, + "Created Loggly logging endpoint %s (service %s version %d)", + fastly.ToValue(d.Name), + fastly.ToValue(d.ServiceID), + fastly.ToValue(d.ServiceVersion), + ) + return nil +} diff --git a/pkg/commands/logging/loggly/delete.go b/pkg/commands/logging/loggly/delete.go new file mode 100644 index 000000000..db3abd641 --- /dev/null +++ b/pkg/commands/logging/loggly/delete.go @@ -0,0 +1,94 @@ +package loggly + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// DeleteCommand calls the Fastly API to delete a Loggly logging endpoint. +type DeleteCommand struct { + argparser.Base + Input fastly.DeleteLogglyInput + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion + autoClone argparser.OptionalAutoClone +} + +// NewDeleteCommand returns a usable command registered under the parent. +func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { + c := DeleteCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("delete", "Delete a Loggly logging endpoint on a Fastly service version").Alias("remove") + + // Required. + c.CmdClause.Flag("name", "The name of the Loggly logging object").Short('n').Required().StringVar(&c.Input.Name) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.autoClone.Set, + Dst: &c.autoClone.Value, + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + return &c +} + +// Exec invokes the application logic for the command. +func (c *DeleteCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.autoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + c.Input.ServiceID = serviceID + c.Input.ServiceVersion = fastly.ToValue(serviceVersion.Number) + + if err := c.Globals.APIClient.DeleteLoggly(&c.Input); err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + text.Success(out, "Deleted Loggly logging endpoint %s (service %s version %d)", c.Input.Name, c.Input.ServiceID, c.Input.ServiceVersion) + return nil +} diff --git a/pkg/commands/logging/loggly/describe.go b/pkg/commands/logging/loggly/describe.go new file mode 100644 index 000000000..8f9c67a2e --- /dev/null +++ b/pkg/commands/logging/loggly/describe.go @@ -0,0 +1,109 @@ +package loggly + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// DescribeCommand calls the Fastly API to describe a Loggly logging endpoint. +type DescribeCommand struct { + argparser.Base + argparser.JSONOutput + + Input fastly.GetLogglyInput + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion +} + +// NewDescribeCommand returns a usable command registered under the parent. +func NewDescribeCommand(parent argparser.Registerer, g *global.Data) *DescribeCommand { + c := DescribeCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("describe", "Show detailed information about a Loggly logging endpoint on a Fastly service version").Alias("get") + + // Required. + c.CmdClause.Flag("name", "The name of the Loggly logging object").Short('n').Required().StringVar(&c.Input.Name) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + return &c +} + +// Exec invokes the application logic for the command. +func (c *DescribeCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fsterr.ServiceVersion(serviceVersion), + }) + return err + } + + c.Input.ServiceID = serviceID + c.Input.ServiceVersion = fastly.ToValue(serviceVersion.Number) + + o, err := c.Globals.APIClient.GetLoggly(&c.Input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + lines := text.Lines{ + "Format version": fastly.ToValue(o.FormatVersion), + "Format": fastly.ToValue(o.Format), + "Name": fastly.ToValue(o.Name), + "Placement": fastly.ToValue(o.Placement), + "Response condition": fastly.ToValue(o.ResponseCondition), + "Token": fastly.ToValue(o.Token), + "Version": fastly.ToValue(o.ServiceVersion), + } + if !c.Globals.Verbose() { + lines["Service ID"] = fastly.ToValue(o.ServiceID) + } + text.PrintLines(out, lines) + + return nil +} diff --git a/pkg/logging/loggly/doc.go b/pkg/commands/logging/loggly/doc.go similarity index 100% rename from pkg/logging/loggly/doc.go rename to pkg/commands/logging/loggly/doc.go diff --git a/pkg/commands/logging/loggly/list.go b/pkg/commands/logging/loggly/list.go new file mode 100644 index 000000000..2c108292b --- /dev/null +++ b/pkg/commands/logging/loggly/list.go @@ -0,0 +1,123 @@ +package loggly + +import ( + "fmt" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// ListCommand calls the Fastly API to list Loggly logging endpoints. +type ListCommand struct { + argparser.Base + argparser.JSONOutput + + Input fastly.ListLogglyInput + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion +} + +// NewListCommand returns a usable command registered under the parent. +func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { + c := ListCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("list", "List Loggly endpoints on a Fastly service version") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + return &c +} + +// Exec invokes the application logic for the command. +func (c *ListCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fsterr.ServiceVersion(serviceVersion), + }) + return err + } + + c.Input.ServiceID = serviceID + c.Input.ServiceVersion = fastly.ToValue(serviceVersion.Number) + + o, err := c.Globals.APIClient.ListLoggly(&c.Input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + if !c.Globals.Verbose() { + tw := text.NewTable(out) + tw.AddHeader("SERVICE", "VERSION", "NAME") + for _, loggly := range o { + tw.AddLine( + fastly.ToValue(loggly.ServiceID), + fastly.ToValue(loggly.ServiceVersion), + fastly.ToValue(loggly.Name), + ) + } + tw.Print() + return nil + } + + fmt.Fprintf(out, "Version: %d\n", c.Input.ServiceVersion) + for i, loggly := range o { + fmt.Fprintf(out, "\tLoggly %d/%d\n", i+1, len(o)) + fmt.Fprintf(out, "\t\tService ID: %s\n", fastly.ToValue(loggly.ServiceID)) + fmt.Fprintf(out, "\t\tVersion: %d\n", fastly.ToValue(loggly.ServiceVersion)) + fmt.Fprintf(out, "\t\tName: %s\n", fastly.ToValue(loggly.Name)) + fmt.Fprintf(out, "\t\tToken: %s\n", fastly.ToValue(loggly.Token)) + fmt.Fprintf(out, "\t\tFormat: %s\n", fastly.ToValue(loggly.Format)) + fmt.Fprintf(out, "\t\tFormat version: %d\n", fastly.ToValue(loggly.FormatVersion)) + fmt.Fprintf(out, "\t\tResponse condition: %s\n", fastly.ToValue(loggly.ResponseCondition)) + fmt.Fprintf(out, "\t\tPlacement: %s\n", fastly.ToValue(loggly.Placement)) + } + fmt.Fprintln(out) + + return nil +} diff --git a/pkg/commands/logging/loggly/loggly_integration_test.go b/pkg/commands/logging/loggly/loggly_integration_test.go new file mode 100644 index 000000000..d7cac70cf --- /dev/null +++ b/pkg/commands/logging/loggly/loggly_integration_test.go @@ -0,0 +1,404 @@ +package loggly_test + +import ( + "bytes" + "errors" + "io" + "strings" + "testing" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/app" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" +) + +func TestLogglyCreate(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging loggly create --service-id 123 --version 1 --name log --auth-token abc --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + CreateLogglyFn: createLogglyOK, + }, + wantOutput: "Created Loggly logging endpoint log (service 123 version 4)", + }, + { + args: args("logging loggly create --service-id 123 --version 1 --name log --auth-token abc --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + CreateLogglyFn: createLogglyError, + }, + wantError: errTest.Error(), + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertStringContains(t, stdout.String(), testcase.wantOutput) + }) + } +} + +func TestLogglyList(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging loggly list --service-id 123 --version 1"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListLogglyFn: listLogglysOK, + }, + wantOutput: listLogglysShortOutput, + }, + { + args: args("logging loggly list --service-id 123 --version 1 --verbose"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListLogglyFn: listLogglysOK, + }, + wantOutput: listLogglysVerboseOutput, + }, + { + args: args("logging loggly list --service-id 123 --version 1 -v"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListLogglyFn: listLogglysOK, + }, + wantOutput: listLogglysVerboseOutput, + }, + { + args: args("logging loggly --verbose list --service-id 123 --version 1"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListLogglyFn: listLogglysOK, + }, + wantOutput: listLogglysVerboseOutput, + }, + { + args: args("logging -v loggly list --service-id 123 --version 1"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListLogglyFn: listLogglysOK, + }, + wantOutput: listLogglysVerboseOutput, + }, + { + args: args("logging loggly list --service-id 123 --version 1"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListLogglyFn: listLogglysError, + }, + wantError: errTest.Error(), + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertString(t, testcase.wantOutput, stdout.String()) + }) + } +} + +func TestLogglyDescribe(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging loggly describe --service-id 123 --version 1"), + wantError: "error parsing arguments: required flag --name not provided", + }, + { + args: args("logging loggly describe --service-id 123 --version 1 --name logs"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + GetLogglyFn: getLogglyError, + }, + wantError: errTest.Error(), + }, + { + args: args("logging loggly describe --service-id 123 --version 1 --name logs"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + GetLogglyFn: getLogglyOK, + }, + wantOutput: describeLogglyOutput, + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertString(t, testcase.wantOutput, stdout.String()) + }) + } +} + +func TestLogglyUpdate(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging loggly update --service-id 123 --version 1 --new-name log"), + wantError: "error parsing arguments: required flag --name not provided", + }, + { + args: args("logging loggly update --service-id 123 --version 1 --name logs --new-name log --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + UpdateLogglyFn: updateLogglyError, + }, + wantError: errTest.Error(), + }, + { + args: args("logging loggly update --service-id 123 --version 1 --name logs --new-name log --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + UpdateLogglyFn: updateLogglyOK, + }, + wantOutput: "Updated Loggly logging endpoint log (service 123 version 4)", + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertStringContains(t, stdout.String(), testcase.wantOutput) + }) + } +} + +func TestLogglyDelete(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging loggly delete --service-id 123 --version 1"), + wantError: "error parsing arguments: required flag --name not provided", + }, + { + args: args("logging loggly delete --service-id 123 --version 1 --name logs --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + DeleteLogglyFn: deleteLogglyError, + }, + wantError: errTest.Error(), + }, + { + args: args("logging loggly delete --service-id 123 --version 1 --name logs --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + DeleteLogglyFn: deleteLogglyOK, + }, + wantOutput: "Deleted Loggly logging endpoint logs (service 123 version 4)", + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertStringContains(t, stdout.String(), testcase.wantOutput) + }) + } +} + +var errTest = errors.New("fixture error") + +func createLogglyOK(i *fastly.CreateLogglyInput) (*fastly.Loggly, error) { + s := fastly.Loggly{ + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + } + + if i.Name != nil { + s.Name = i.Name + } + + return &s, nil +} + +func createLogglyError(_ *fastly.CreateLogglyInput) (*fastly.Loggly, error) { + return nil, errTest +} + +func listLogglysOK(i *fastly.ListLogglyInput) ([]*fastly.Loggly, error) { + return []*fastly.Loggly{ + { + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("logs"), + Token: fastly.ToPointer("abc"), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + FormatVersion: fastly.ToPointer(2), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + Placement: fastly.ToPointer("none"), + }, + { + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("analytics"), + Token: fastly.ToPointer("abc"), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + FormatVersion: fastly.ToPointer(2), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + Placement: fastly.ToPointer("none"), + }, + }, nil +} + +func listLogglysError(_ *fastly.ListLogglyInput) ([]*fastly.Loggly, error) { + return nil, errTest +} + +var listLogglysShortOutput = strings.TrimSpace(` +SERVICE VERSION NAME +123 1 logs +123 1 analytics +`) + "\n" + +var listLogglysVerboseOutput = strings.TrimSpace(` +Fastly API endpoint: https://api.fastly.com +Fastly API token provided via config file (profile: user) + +Service ID (via --service-id): 123 + +Version: 1 + Loggly 1/2 + Service ID: 123 + Version: 1 + Name: logs + Token: abc + Format: %h %l %u %t "%r" %>s %b + Format version: 2 + Response condition: Prevent default logging + Placement: none + Loggly 2/2 + Service ID: 123 + Version: 1 + Name: analytics + Token: abc + Format: %h %l %u %t "%r" %>s %b + Format version: 2 + Response condition: Prevent default logging + Placement: none +`) + "\n\n" + +func getLogglyOK(i *fastly.GetLogglyInput) (*fastly.Loggly, error) { + return &fastly.Loggly{ + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("logs"), + Token: fastly.ToPointer("abc"), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + FormatVersion: fastly.ToPointer(2), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + Placement: fastly.ToPointer("none"), + }, nil +} + +func getLogglyError(_ *fastly.GetLogglyInput) (*fastly.Loggly, error) { + return nil, errTest +} + +var describeLogglyOutput = "\n" + strings.TrimSpace(` +Format: %h %l %u %t "%r" %>s %b +Format version: 2 +Name: logs +Placement: none +Response condition: Prevent default logging +Service ID: 123 +Token: abc +Version: 1 +`) + "\n" + +func updateLogglyOK(i *fastly.UpdateLogglyInput) (*fastly.Loggly, error) { + return &fastly.Loggly{ + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("log"), + Token: fastly.ToPointer("abc"), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + FormatVersion: fastly.ToPointer(2), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + }, nil +} + +func updateLogglyError(_ *fastly.UpdateLogglyInput) (*fastly.Loggly, error) { + return nil, errTest +} + +func deleteLogglyOK(_ *fastly.DeleteLogglyInput) error { + return nil +} + +func deleteLogglyError(_ *fastly.DeleteLogglyInput) error { + return errTest +} diff --git a/pkg/commands/logging/loggly/loggly_test.go b/pkg/commands/logging/loggly/loggly_test.go new file mode 100644 index 000000000..6f36345dc --- /dev/null +++ b/pkg/commands/logging/loggly/loggly_test.go @@ -0,0 +1,336 @@ +package loggly_test + +import ( + "bytes" + "testing" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/logging/loggly" + "github.com/fastly/cli/pkg/config" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" +) + +func TestCreateLogglyInput(t *testing.T) { + for _, testcase := range []struct { + name string + cmd *loggly.CreateCommand + want *fastly.CreateLogglyInput + wantError string + }{ + { + name: "required values set flag serviceID", + cmd: createCommandRequired(), + want: &fastly.CreateLogglyInput{ + ServiceID: "123", + ServiceVersion: 4, + Name: fastly.ToPointer("log"), + Token: fastly.ToPointer("tkn"), + }, + }, + { + name: "all values set flag serviceID", + cmd: createCommandOK(), + want: &fastly.CreateLogglyInput{ + ServiceID: "123", + ServiceVersion: 4, + Name: fastly.ToPointer("log"), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + FormatVersion: fastly.ToPointer(2), + Token: fastly.ToPointer("tkn"), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + Placement: fastly.ToPointer("none"), + }, + }, + { + name: "error missing serviceID", + cmd: createCommandMissingServiceID(), + want: nil, + wantError: errors.ErrNoServiceID.Error(), + }, + } { + t.Run(testcase.name, func(t *testing.T) { + var bs []byte + out := bytes.NewBuffer(bs) + verboseMode := true + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + AutoCloneFlag: testcase.cmd.AutoClone, + APIClient: testcase.cmd.Globals.APIClient, + Manifest: testcase.cmd.Manifest, + Out: out, + ServiceVersionFlag: testcase.cmd.ServiceVersion, + VerboseMode: verboseMode, + }) + + switch { + case err != nil && testcase.wantError == "": + t.Fatalf("unexpected error getting service details: %v", err) + return + case err != nil && testcase.wantError != "": + testutil.AssertErrorContains(t, err, testcase.wantError) + return + case err == nil && testcase.wantError != "": + t.Fatalf("expected error, have nil (service details: %s, %d)", serviceID, serviceVersion.Number) + case err == nil && testcase.wantError == "": + have, err := testcase.cmd.ConstructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertEqual(t, testcase.want, have) + } + }) + } +} + +func TestUpdateLogglyInput(t *testing.T) { + scenarios := []struct { + name string + cmd *loggly.UpdateCommand + api mock.API + want *fastly.UpdateLogglyInput + wantError string + }{ + { + name: "no updates", + cmd: updateCommandNoUpdates(), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + GetLogglyFn: getLogglyOK, + }, + want: &fastly.UpdateLogglyInput{ + ServiceID: "123", + ServiceVersion: 4, + Name: "log", + }, + }, + { + name: "all values set flag serviceID", + cmd: updateCommandAll(), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + GetLogglyFn: getLogglyOK, + }, + want: &fastly.UpdateLogglyInput{ + ServiceID: "123", + ServiceVersion: 4, + Name: "log", + NewName: fastly.ToPointer("new1"), + Format: fastly.ToPointer("new2"), + FormatVersion: fastly.ToPointer(3), + Token: fastly.ToPointer("new3"), + ResponseCondition: fastly.ToPointer("new4"), + Placement: fastly.ToPointer("new5"), + }, + }, + { + name: "error missing serviceID", + cmd: updateCommandMissingServiceID(), + want: nil, + wantError: errors.ErrNoServiceID.Error(), + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(testcase.name, func(t *testing.T) { + testcase.cmd.Globals.APIClient = testcase.api + + var bs []byte + out := bytes.NewBuffer(bs) + verboseMode := true + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + AutoCloneFlag: testcase.cmd.AutoClone, + APIClient: testcase.api, + Manifest: testcase.cmd.Manifest, + Out: out, + ServiceVersionFlag: testcase.cmd.ServiceVersion, + VerboseMode: verboseMode, + }) + + switch { + case err != nil && testcase.wantError == "": + t.Fatalf("unexpected error getting service details: %v", err) + return + case err != nil && testcase.wantError != "": + testutil.AssertErrorContains(t, err, testcase.wantError) + return + case err == nil && testcase.wantError != "": + t.Fatalf("expected error, have nil (service details: %s, %d)", serviceID, serviceVersion.Number) + case err == nil && testcase.wantError == "": + have, err := testcase.cmd.ConstructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertEqual(t, testcase.want, have) + } + }) + } +} + +func createCommandOK() *loggly.CreateCommand { + var b bytes.Buffer + + g := global.Data{ + Config: config.File{}, + Env: config.Environment{}, + Output: &b, + } + g.APIClient, _ = mock.APIClient(mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + })("token", "endpoint", false) + + return &loggly.CreateCommand{ + Base: argparser.Base{ + Globals: &g, + }, + Manifest: manifest.Data{ + Flag: manifest.Flag{ + ServiceID: "123", + }, + }, + EndpointName: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "log"}, + Token: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "tkn"}, + ServiceVersion: argparser.OptionalServiceVersion{ + OptionalString: argparser.OptionalString{Value: "1"}, + }, + AutoClone: argparser.OptionalAutoClone{ + OptionalBool: argparser.OptionalBool{ + Optional: argparser.Optional{ + WasSet: true, + }, + Value: true, + }, + }, + Format: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: `%h %l %u %t "%r" %>s %b`}, + FormatVersion: argparser.OptionalInt{Optional: argparser.Optional{WasSet: true}, Value: 2}, + ResponseCondition: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "Prevent default logging"}, + Placement: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "none"}, + } +} + +func createCommandRequired() *loggly.CreateCommand { + var b bytes.Buffer + + g := global.Data{ + Config: config.File{}, + Env: config.Environment{}, + Output: &b, + } + g.APIClient, _ = mock.APIClient(mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + })("token", "endpoint", false) + + return &loggly.CreateCommand{ + Base: argparser.Base{ + Globals: &g, + }, + Manifest: manifest.Data{ + Flag: manifest.Flag{ + ServiceID: "123", + }, + }, + EndpointName: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "log"}, + Token: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "tkn"}, + ServiceVersion: argparser.OptionalServiceVersion{ + OptionalString: argparser.OptionalString{Value: "1"}, + }, + AutoClone: argparser.OptionalAutoClone{ + OptionalBool: argparser.OptionalBool{ + Optional: argparser.Optional{ + WasSet: true, + }, + Value: true, + }, + }, + } +} + +func createCommandMissingServiceID() *loggly.CreateCommand { + res := createCommandOK() + res.Manifest = manifest.Data{} + return res +} + +func updateCommandNoUpdates() *loggly.UpdateCommand { + var b bytes.Buffer + + g := global.Data{ + Config: config.File{}, + Env: config.Environment{}, + Output: &b, + } + + return &loggly.UpdateCommand{ + Base: argparser.Base{ + Globals: &g, + }, + Manifest: manifest.Data{ + Flag: manifest.Flag{ + ServiceID: "123", + }, + }, + EndpointName: "log", + ServiceVersion: argparser.OptionalServiceVersion{ + OptionalString: argparser.OptionalString{Value: "1"}, + }, + AutoClone: argparser.OptionalAutoClone{ + OptionalBool: argparser.OptionalBool{ + Optional: argparser.Optional{ + WasSet: true, + }, + Value: true, + }, + }, + } +} + +func updateCommandAll() *loggly.UpdateCommand { + var b bytes.Buffer + + g := global.Data{ + Config: config.File{}, + Env: config.Environment{}, + Output: &b, + } + + return &loggly.UpdateCommand{ + Base: argparser.Base{ + Globals: &g, + }, + Manifest: manifest.Data{ + Flag: manifest.Flag{ + ServiceID: "123", + }, + }, + EndpointName: "log", + ServiceVersion: argparser.OptionalServiceVersion{ + OptionalString: argparser.OptionalString{Value: "1"}, + }, + AutoClone: argparser.OptionalAutoClone{ + OptionalBool: argparser.OptionalBool{ + Optional: argparser.Optional{ + WasSet: true, + }, + Value: true, + }, + }, + NewName: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new1"}, + Format: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new2"}, + FormatVersion: argparser.OptionalInt{Optional: argparser.Optional{WasSet: true}, Value: 3}, + Token: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new3"}, + ResponseCondition: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new4"}, + Placement: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new5"}, + } +} + +func updateCommandMissingServiceID() *loggly.UpdateCommand { + res := updateCommandAll() + res.Manifest = manifest.Data{} + return res +} diff --git a/pkg/commands/logging/loggly/root.go b/pkg/commands/logging/loggly/root.go new file mode 100644 index 000000000..34f977859 --- /dev/null +++ b/pkg/commands/logging/loggly/root.go @@ -0,0 +1,31 @@ +package loggly + +import ( + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" +) + +// RootCommand is the parent command for all subcommands in this package. +// It should be installed under the primary root command. +type RootCommand struct { + argparser.Base + // no flags +} + +// CommandName is the string to be used to invoke this command. +const CommandName = "loggly" + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent argparser.Registerer, g *global.Data) *RootCommand { + var c RootCommand + c.Globals = g + c.CmdClause = parent.Command(CommandName, "Manipulate Fastly service version Loggly logging endpoints") + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { + panic("unreachable") +} diff --git a/pkg/commands/logging/loggly/update.go b/pkg/commands/logging/loggly/update.go new file mode 100644 index 000000000..b33006b5a --- /dev/null +++ b/pkg/commands/logging/loggly/update.go @@ -0,0 +1,157 @@ +package loggly + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/logging/common" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/text" +) + +// UpdateCommand calls the Fastly API to update a Loggly logging endpoint. +type UpdateCommand struct { + argparser.Base + Manifest manifest.Data + + // Required. + EndpointName string // Can't shadow argparser.Base method Name(). + ServiceName argparser.OptionalServiceNameID + ServiceVersion argparser.OptionalServiceVersion + + // Optional. + AutoClone argparser.OptionalAutoClone + NewName argparser.OptionalString + Format argparser.OptionalString + FormatVersion argparser.OptionalInt + Token argparser.OptionalString + ResponseCondition argparser.OptionalString + Placement argparser.OptionalString +} + +// NewUpdateCommand returns a usable command registered under the parent. +func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { + c := UpdateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("update", "Update a Loggly logging endpoint on a Fastly service version") + + // Required. + c.CmdClause.Flag("name", "The name of the Loggly logging object").Short('n').Required().StringVar(&c.EndpointName) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.ServiceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.AutoClone.Set, + Dst: &c.AutoClone.Value, + }) + c.CmdClause.Flag("auth-token", "The token to use for authentication (https://www.loggly.com/docs/customer-token-authentication-token/)").Action(c.Token.Set).StringVar(&c.Token.Value) + common.Format(c.CmdClause, &c.Format) + common.FormatVersion(c.CmdClause, &c.FormatVersion) + c.CmdClause.Flag("new-name", "New name of the Loggly logging object").Action(c.NewName.Set).StringVar(&c.NewName.Value) + common.Placement(c.CmdClause, &c.Placement) + common.ResponseCondition(c.CmdClause, &c.ResponseCondition) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.ServiceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.ServiceName.Value, + }) + return &c +} + +// ConstructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *UpdateCommand) ConstructInput(serviceID string, serviceVersion int) (*fastly.UpdateLogglyInput, error) { + input := fastly.UpdateLogglyInput{ + ServiceID: serviceID, + ServiceVersion: serviceVersion, + Name: c.EndpointName, + } + + if c.NewName.WasSet { + input.NewName = &c.NewName.Value + } + + if c.Format.WasSet { + input.Format = fastly.ToPointer(argparser.Content(c.Format.Value)) + } + + if c.FormatVersion.WasSet { + input.FormatVersion = &c.FormatVersion.Value + } + + if c.Token.WasSet { + input.Token = &c.Token.Value + } + + if c.ResponseCondition.WasSet { + input.ResponseCondition = &c.ResponseCondition.Value + } + + if c.Placement.WasSet { + input.Placement = &c.Placement.Value + } + + return &input, nil +} + +// Exec invokes the application logic for the command. +func (c *UpdateCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.AutoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.ServiceName, + ServiceVersionFlag: c.ServiceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + input, err := c.ConstructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + loggly, err := c.Globals.APIClient.UpdateLoggly(input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + text.Success(out, + "Updated Loggly logging endpoint %s (service %s version %d)", + fastly.ToValue(loggly.Name), + fastly.ToValue(loggly.ServiceID), + fastly.ToValue(loggly.ServiceVersion), + ) + return nil +} diff --git a/pkg/commands/logging/logshuttle/create.go b/pkg/commands/logging/logshuttle/create.go new file mode 100644 index 000000000..756975e0c --- /dev/null +++ b/pkg/commands/logging/logshuttle/create.go @@ -0,0 +1,157 @@ +package logshuttle + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/logging/common" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/text" +) + +// CreateCommand calls the Fastly API to create a Logshuttle logging endpoint. +type CreateCommand struct { + argparser.Base + Manifest manifest.Data + + // Required. + ServiceName argparser.OptionalServiceNameID + ServiceVersion argparser.OptionalServiceVersion + + // Optional. + AutoClone argparser.OptionalAutoClone + EndpointName argparser.OptionalString // Can't shadow argparser.Base method Name(). + Format argparser.OptionalString + FormatVersion argparser.OptionalInt + Placement argparser.OptionalString + ResponseCondition argparser.OptionalString + Token argparser.OptionalString + URL argparser.OptionalString +} + +// NewCreateCommand returns a usable command registered under the parent. +func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { + c := CreateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("create", "Create a Logshuttle logging endpoint on a Fastly service version").Alias("add") + + // Required. + c.CmdClause.Flag("name", "The name of the Logshuttle logging object. Used as a primary key for API access").Short('n').Action(c.EndpointName.Set).StringVar(&c.EndpointName.Value) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.ServiceVersion.Value, + Required: true, + }) + + // Optional. + c.CmdClause.Flag("auth-token", "The data authentication token associated with this endpoint").Action(c.Token.Set).StringVar(&c.Token.Value) + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.AutoClone.Set, + Dst: &c.AutoClone.Value, + }) + common.Format(c.CmdClause, &c.Format) + common.FormatVersion(c.CmdClause, &c.FormatVersion) + common.ResponseCondition(c.CmdClause, &c.ResponseCondition) + common.Placement(c.CmdClause, &c.Placement) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.ServiceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.ServiceName.Value, + }) + c.CmdClause.Flag("url", "Your Log Shuttle endpoint url").Action(c.URL.Set).StringVar(&c.URL.Value) + return &c +} + +// ConstructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *CreateCommand) ConstructInput(serviceID string, serviceVersion int) (*fastly.CreateLogshuttleInput, error) { + var input fastly.CreateLogshuttleInput + + input.ServiceID = serviceID + input.ServiceVersion = serviceVersion + if c.EndpointName.WasSet { + input.Name = &c.EndpointName.Value + } + if c.Token.WasSet { + input.Token = &c.Token.Value + } + if c.URL.WasSet { + input.URL = &c.URL.Value + } + + if c.Format.WasSet { + input.Format = fastly.ToPointer(argparser.Content(c.Format.Value)) + } + + if c.FormatVersion.WasSet { + input.FormatVersion = &c.FormatVersion.Value + } + + if c.ResponseCondition.WasSet { + input.ResponseCondition = &c.ResponseCondition.Value + } + + if c.Placement.WasSet { + input.Placement = &c.Placement.Value + } + + return &input, nil +} + +// Exec invokes the application logic for the command. +func (c *CreateCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.AutoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.ServiceName, + ServiceVersionFlag: c.ServiceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + input, err := c.ConstructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + d, err := c.Globals.APIClient.CreateLogshuttle(input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + text.Success(out, + "Created Logshuttle logging endpoint %s (service %s version %d)", + fastly.ToValue(d.Name), + fastly.ToValue(d.ServiceID), + fastly.ToValue(d.ServiceVersion), + ) + return nil +} diff --git a/pkg/commands/logging/logshuttle/delete.go b/pkg/commands/logging/logshuttle/delete.go new file mode 100644 index 000000000..f7aa26a6b --- /dev/null +++ b/pkg/commands/logging/logshuttle/delete.go @@ -0,0 +1,94 @@ +package logshuttle + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// DeleteCommand calls the Fastly API to delete a Logshuttle logging endpoint. +type DeleteCommand struct { + argparser.Base + Input fastly.DeleteLogshuttleInput + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion + autoClone argparser.OptionalAutoClone +} + +// NewDeleteCommand returns a usable command registered under the parent. +func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { + c := DeleteCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("delete", "Delete a Logshuttle logging endpoint on a Fastly service version").Alias("remove") + + // Required. + c.CmdClause.Flag("name", "The name of the Logshuttle logging object").Short('n').Required().StringVar(&c.Input.Name) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.autoClone.Set, + Dst: &c.autoClone.Value, + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + return &c +} + +// Exec invokes the application logic for the command. +func (c *DeleteCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.autoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + c.Input.ServiceID = serviceID + c.Input.ServiceVersion = fastly.ToValue(serviceVersion.Number) + + if err := c.Globals.APIClient.DeleteLogshuttle(&c.Input); err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + text.Success(out, "Deleted Logshuttle logging endpoint %s (service %s version %d)", c.Input.Name, c.Input.ServiceID, c.Input.ServiceVersion) + return nil +} diff --git a/pkg/commands/logging/logshuttle/describe.go b/pkg/commands/logging/logshuttle/describe.go new file mode 100644 index 000000000..8868fde86 --- /dev/null +++ b/pkg/commands/logging/logshuttle/describe.go @@ -0,0 +1,110 @@ +package logshuttle + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// DescribeCommand calls the Fastly API to describe a Logshuttle logging endpoint. +type DescribeCommand struct { + argparser.Base + argparser.JSONOutput + + Input fastly.GetLogshuttleInput + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion +} + +// NewDescribeCommand returns a usable command registered under the parent. +func NewDescribeCommand(parent argparser.Registerer, g *global.Data) *DescribeCommand { + c := DescribeCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("describe", "Show detailed information about a Logshuttle logging endpoint on a Fastly service version").Alias("get") + + // Required. + c.CmdClause.Flag("name", "The name of the Logshuttle logging object").Short('n').Required().StringVar(&c.Input.Name) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + return &c +} + +// Exec invokes the application logic for the command. +func (c *DescribeCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fsterr.ServiceVersion(serviceVersion), + }) + return err + } + + c.Input.ServiceID = serviceID + c.Input.ServiceVersion = fastly.ToValue(serviceVersion.Number) + + o, err := c.Globals.APIClient.GetLogshuttle(&c.Input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + lines := text.Lines{ + "Format version": fastly.ToValue(o.FormatVersion), + "Format": fastly.ToValue(o.Format), + "Name": fastly.ToValue(o.Name), + "Placement": fastly.ToValue(o.Placement), + "Response condition": fastly.ToValue(o.ResponseCondition), + "Token": fastly.ToValue(o.Token), + "URL": fastly.ToValue(o.URL), + "Version": fastly.ToValue(o.ServiceVersion), + } + if !c.Globals.Verbose() { + lines["Service ID"] = fastly.ToValue(o.ServiceID) + } + text.PrintLines(out, lines) + + return nil +} diff --git a/pkg/logging/logshuttle/doc.go b/pkg/commands/logging/logshuttle/doc.go similarity index 100% rename from pkg/logging/logshuttle/doc.go rename to pkg/commands/logging/logshuttle/doc.go diff --git a/pkg/commands/logging/logshuttle/list.go b/pkg/commands/logging/logshuttle/list.go new file mode 100644 index 000000000..72e7eee78 --- /dev/null +++ b/pkg/commands/logging/logshuttle/list.go @@ -0,0 +1,124 @@ +package logshuttle + +import ( + "fmt" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// ListCommand calls the Fastly API to list Logshuttle logging endpoints. +type ListCommand struct { + argparser.Base + argparser.JSONOutput + + Input fastly.ListLogshuttlesInput + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion +} + +// NewListCommand returns a usable command registered under the parent. +func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { + c := ListCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("list", "List Logshuttle endpoints on a Fastly service version") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + return &c +} + +// Exec invokes the application logic for the command. +func (c *ListCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fsterr.ServiceVersion(serviceVersion), + }) + return err + } + + c.Input.ServiceID = serviceID + c.Input.ServiceVersion = fastly.ToValue(serviceVersion.Number) + + o, err := c.Globals.APIClient.ListLogshuttles(&c.Input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + if !c.Globals.Verbose() { + tw := text.NewTable(out) + tw.AddHeader("SERVICE", "VERSION", "NAME") + for _, logshuttle := range o { + tw.AddLine( + fastly.ToValue(logshuttle.ServiceID), + fastly.ToValue(logshuttle.ServiceVersion), + fastly.ToValue(logshuttle.Name), + ) + } + tw.Print() + return nil + } + + fmt.Fprintf(out, "Version: %d\n", c.Input.ServiceVersion) + for i, logshuttle := range o { + fmt.Fprintf(out, "\tLogshuttle %d/%d\n", i+1, len(o)) + fmt.Fprintf(out, "\t\tService ID: %s\n", fastly.ToValue(logshuttle.ServiceID)) + fmt.Fprintf(out, "\t\tVersion: %d\n", fastly.ToValue(logshuttle.ServiceVersion)) + fmt.Fprintf(out, "\t\tName: %s\n", fastly.ToValue(logshuttle.Name)) + fmt.Fprintf(out, "\t\tURL: %s\n", fastly.ToValue(logshuttle.URL)) + fmt.Fprintf(out, "\t\tToken: %s\n", fastly.ToValue(logshuttle.Token)) + fmt.Fprintf(out, "\t\tFormat: %s\n", fastly.ToValue(logshuttle.Format)) + fmt.Fprintf(out, "\t\tFormat version: %d\n", fastly.ToValue(logshuttle.FormatVersion)) + fmt.Fprintf(out, "\t\tResponse condition: %s\n", fastly.ToValue(logshuttle.ResponseCondition)) + fmt.Fprintf(out, "\t\tPlacement: %s\n", fastly.ToValue(logshuttle.Placement)) + } + fmt.Fprintln(out) + + return nil +} diff --git a/pkg/commands/logging/logshuttle/logshuttle_integration_test.go b/pkg/commands/logging/logshuttle/logshuttle_integration_test.go new file mode 100644 index 000000000..1604e7765 --- /dev/null +++ b/pkg/commands/logging/logshuttle/logshuttle_integration_test.go @@ -0,0 +1,412 @@ +package logshuttle_test + +import ( + "bytes" + "errors" + "io" + "strings" + "testing" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/app" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" +) + +func TestLogshuttleCreate(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging logshuttle create --service-id 123 --version 1 --name log --url example.com --auth-token abc --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + CreateLogshuttleFn: createLogshuttleOK, + }, + wantOutput: "Created Logshuttle logging endpoint log (service 123 version 4)", + }, + { + args: args("logging logshuttle create --service-id 123 --version 1 --name log --url example.com --auth-token abc --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + CreateLogshuttleFn: createLogshuttleError, + }, + wantError: errTest.Error(), + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertStringContains(t, stdout.String(), testcase.wantOutput) + }) + } +} + +func TestLogshuttleList(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging logshuttle list --service-id 123 --version 1"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListLogshuttlesFn: listLogshuttlesOK, + }, + wantOutput: listLogshuttlesShortOutput, + }, + { + args: args("logging logshuttle list --service-id 123 --version 1 --verbose"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListLogshuttlesFn: listLogshuttlesOK, + }, + wantOutput: listLogshuttlesVerboseOutput, + }, + { + args: args("logging logshuttle list --service-id 123 --version 1 -v"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListLogshuttlesFn: listLogshuttlesOK, + }, + wantOutput: listLogshuttlesVerboseOutput, + }, + { + args: args("logging logshuttle --verbose list --service-id 123 --version 1"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListLogshuttlesFn: listLogshuttlesOK, + }, + wantOutput: listLogshuttlesVerboseOutput, + }, + { + args: args("logging -v logshuttle list --service-id 123 --version 1"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListLogshuttlesFn: listLogshuttlesOK, + }, + wantOutput: listLogshuttlesVerboseOutput, + }, + { + args: args("logging logshuttle list --service-id 123 --version 1"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListLogshuttlesFn: listLogshuttlesError, + }, + wantError: errTest.Error(), + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertString(t, testcase.wantOutput, stdout.String()) + }) + } +} + +func TestLogshuttleDescribe(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging logshuttle describe --service-id 123 --version 1"), + wantError: "error parsing arguments: required flag --name not provided", + }, + { + args: args("logging logshuttle describe --service-id 123 --version 1 --name logs"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + GetLogshuttleFn: getLogshuttleError, + }, + wantError: errTest.Error(), + }, + { + args: args("logging logshuttle describe --service-id 123 --version 1 --name logs"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + GetLogshuttleFn: getLogshuttleOK, + }, + wantOutput: describeLogshuttleOutput, + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertString(t, testcase.wantOutput, stdout.String()) + }) + } +} + +func TestLogshuttleUpdate(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging logshuttle update --service-id 123 --version 1 --new-name log"), + wantError: "error parsing arguments: required flag --name not provided", + }, + { + args: args("logging logshuttle update --service-id 123 --version 1 --name logs --new-name log --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + UpdateLogshuttleFn: updateLogshuttleError, + }, + wantError: errTest.Error(), + }, + { + args: args("logging logshuttle update --service-id 123 --version 1 --name logs --new-name log --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + UpdateLogshuttleFn: updateLogshuttleOK, + }, + wantOutput: "Updated Logshuttle logging endpoint log (service 123 version 4)", + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertStringContains(t, stdout.String(), testcase.wantOutput) + }) + } +} + +func TestLogshuttleDelete(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging logshuttle delete --service-id 123 --version 1"), + wantError: "error parsing arguments: required flag --name not provided", + }, + { + args: args("logging logshuttle delete --service-id 123 --version 1 --name logs --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + DeleteLogshuttleFn: deleteLogshuttleError, + }, + wantError: errTest.Error(), + }, + { + args: args("logging logshuttle delete --service-id 123 --version 1 --name logs --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + DeleteLogshuttleFn: deleteLogshuttleOK, + }, + wantOutput: "Deleted Logshuttle logging endpoint logs (service 123 version 4)", + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertStringContains(t, stdout.String(), testcase.wantOutput) + }) + } +} + +var errTest = errors.New("fixture error") + +func createLogshuttleOK(i *fastly.CreateLogshuttleInput) (*fastly.Logshuttle, error) { + s := fastly.Logshuttle{ + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + } + + if i.Name != nil { + s.Name = i.Name + } + + return &s, nil +} + +func createLogshuttleError(_ *fastly.CreateLogshuttleInput) (*fastly.Logshuttle, error) { + return nil, errTest +} + +func listLogshuttlesOK(i *fastly.ListLogshuttlesInput) ([]*fastly.Logshuttle, error) { + return []*fastly.Logshuttle{ + { + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("logs"), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + FormatVersion: fastly.ToPointer(2), + URL: fastly.ToPointer("example.com"), + Token: fastly.ToPointer("abc"), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + Placement: fastly.ToPointer("none"), + }, + { + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("analytics"), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + FormatVersion: fastly.ToPointer(2), + URL: fastly.ToPointer("example.com"), + Token: fastly.ToPointer("abc"), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + Placement: fastly.ToPointer("none"), + }, + }, nil +} + +func listLogshuttlesError(_ *fastly.ListLogshuttlesInput) ([]*fastly.Logshuttle, error) { + return nil, errTest +} + +var listLogshuttlesShortOutput = strings.TrimSpace(` +SERVICE VERSION NAME +123 1 logs +123 1 analytics +`) + "\n" + +var listLogshuttlesVerboseOutput = strings.TrimSpace(` +Fastly API endpoint: https://api.fastly.com +Fastly API token provided via config file (profile: user) + +Service ID (via --service-id): 123 + +Version: 1 + Logshuttle 1/2 + Service ID: 123 + Version: 1 + Name: logs + URL: example.com + Token: abc + Format: %h %l %u %t "%r" %>s %b + Format version: 2 + Response condition: Prevent default logging + Placement: none + Logshuttle 2/2 + Service ID: 123 + Version: 1 + Name: analytics + URL: example.com + Token: abc + Format: %h %l %u %t "%r" %>s %b + Format version: 2 + Response condition: Prevent default logging + Placement: none +`) + "\n\n" + +func getLogshuttleOK(i *fastly.GetLogshuttleInput) (*fastly.Logshuttle, error) { + return &fastly.Logshuttle{ + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("logs"), + URL: fastly.ToPointer("example.com"), + Token: fastly.ToPointer("abc"), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + FormatVersion: fastly.ToPointer(2), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + Placement: fastly.ToPointer("none"), + }, nil +} + +func getLogshuttleError(_ *fastly.GetLogshuttleInput) (*fastly.Logshuttle, error) { + return nil, errTest +} + +var describeLogshuttleOutput = "\n" + strings.TrimSpace(` +Format: %h %l %u %t "%r" %>s %b +Format version: 2 +Name: logs +Placement: none +Response condition: Prevent default logging +Service ID: 123 +Token: abc +URL: example.com +Version: 1 +`) + "\n" + +func updateLogshuttleOK(i *fastly.UpdateLogshuttleInput) (*fastly.Logshuttle, error) { + return &fastly.Logshuttle{ + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("log"), + URL: fastly.ToPointer("example.com"), + Token: fastly.ToPointer("abc"), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + FormatVersion: fastly.ToPointer(2), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + Placement: fastly.ToPointer("none"), + }, nil +} + +func updateLogshuttleError(_ *fastly.UpdateLogshuttleInput) (*fastly.Logshuttle, error) { + return nil, errTest +} + +func deleteLogshuttleOK(_ *fastly.DeleteLogshuttleInput) error { + return nil +} + +func deleteLogshuttleError(_ *fastly.DeleteLogshuttleInput) error { + return errTest +} diff --git a/pkg/commands/logging/logshuttle/logshuttle_test.go b/pkg/commands/logging/logshuttle/logshuttle_test.go new file mode 100644 index 000000000..b11ebaabd --- /dev/null +++ b/pkg/commands/logging/logshuttle/logshuttle_test.go @@ -0,0 +1,342 @@ +package logshuttle_test + +import ( + "bytes" + "testing" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/logging/logshuttle" + "github.com/fastly/cli/pkg/config" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" +) + +func TestCreateLogshuttleInput(t *testing.T) { + for _, testcase := range []struct { + name string + cmd *logshuttle.CreateCommand + want *fastly.CreateLogshuttleInput + wantError string + }{ + { + name: "required values set flag serviceID", + cmd: createCommandRequired(), + want: &fastly.CreateLogshuttleInput{ + ServiceID: "123", + ServiceVersion: 4, + Name: fastly.ToPointer("log"), + Token: fastly.ToPointer("tkn"), + URL: fastly.ToPointer("example.com"), + }, + }, + { + name: "all values set flag serviceID", + cmd: createCommandAll(), + want: &fastly.CreateLogshuttleInput{ + ServiceID: "123", + ServiceVersion: 4, + Name: fastly.ToPointer("log"), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + FormatVersion: fastly.ToPointer(2), + URL: fastly.ToPointer("example.com"), + Token: fastly.ToPointer("tkn"), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + Placement: fastly.ToPointer("none"), + }, + }, + { + name: "error missing serviceID", + cmd: createCommandMissingServiceID(), + want: nil, + wantError: errors.ErrNoServiceID.Error(), + }, + } { + t.Run(testcase.name, func(t *testing.T) { + var bs []byte + out := bytes.NewBuffer(bs) + verboseMode := true + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + AutoCloneFlag: testcase.cmd.AutoClone, + APIClient: testcase.cmd.Globals.APIClient, + Manifest: testcase.cmd.Manifest, + Out: out, + ServiceVersionFlag: testcase.cmd.ServiceVersion, + VerboseMode: verboseMode, + }) + + switch { + case err != nil && testcase.wantError == "": + t.Fatalf("unexpected error getting service details: %v", err) + return + case err != nil && testcase.wantError != "": + testutil.AssertErrorContains(t, err, testcase.wantError) + return + case err == nil && testcase.wantError != "": + t.Fatalf("expected error, have nil (service details: %s, %d)", serviceID, serviceVersion.Number) + case err == nil && testcase.wantError == "": + have, err := testcase.cmd.ConstructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertEqual(t, testcase.want, have) + } + }) + } +} + +func TestUpdateLogshuttleInput(t *testing.T) { + scenarios := []struct { + name string + cmd *logshuttle.UpdateCommand + api mock.API + want *fastly.UpdateLogshuttleInput + wantError string + }{ + { + name: "no update", + cmd: updateCommandNoUpdate(), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + GetLogshuttleFn: getLogshuttleOK, + }, + want: &fastly.UpdateLogshuttleInput{ + ServiceID: "123", + ServiceVersion: 4, + Name: "log", + }, + }, + { + name: "all values set flag serviceID", + cmd: updateCommandAll(), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + GetLogshuttleFn: getLogshuttleOK, + }, + want: &fastly.UpdateLogshuttleInput{ + ServiceID: "123", + ServiceVersion: 4, + Name: "log", + NewName: fastly.ToPointer("new1"), + Format: fastly.ToPointer("new2"), + FormatVersion: fastly.ToPointer(3), + Token: fastly.ToPointer("new3"), + URL: fastly.ToPointer("new4"), + ResponseCondition: fastly.ToPointer("new5"), + Placement: fastly.ToPointer("new6"), + }, + }, + { + name: "error missing serviceID", + cmd: updateCommandMissingServiceID(), + want: nil, + wantError: errors.ErrNoServiceID.Error(), + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(testcase.name, func(t *testing.T) { + testcase.cmd.Globals.APIClient = testcase.api + + var bs []byte + out := bytes.NewBuffer(bs) + verboseMode := true + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + AutoCloneFlag: testcase.cmd.AutoClone, + APIClient: testcase.api, + Manifest: testcase.cmd.Manifest, + Out: out, + ServiceVersionFlag: testcase.cmd.ServiceVersion, + VerboseMode: verboseMode, + }) + + switch { + case err != nil && testcase.wantError == "": + t.Fatalf("unexpected error getting service details: %v", err) + return + case err != nil && testcase.wantError != "": + testutil.AssertErrorContains(t, err, testcase.wantError) + return + case err == nil && testcase.wantError != "": + t.Fatalf("expected error, have nil (service details: %s, %d)", serviceID, serviceVersion.Number) + case err == nil && testcase.wantError == "": + have, err := testcase.cmd.ConstructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertEqual(t, testcase.want, have) + } + }) + } +} + +func createCommandRequired() *logshuttle.CreateCommand { + var b bytes.Buffer + + g := global.Data{ + Config: config.File{}, + Env: config.Environment{}, + Output: &b, + } + g.APIClient, _ = mock.APIClient(mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + })("token", "endpoint", false) + + return &logshuttle.CreateCommand{ + Base: argparser.Base{ + Globals: &g, + }, + Manifest: manifest.Data{ + Flag: manifest.Flag{ + ServiceID: "123", + }, + }, + EndpointName: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "log"}, + Token: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "tkn"}, + URL: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "example.com"}, + ServiceVersion: argparser.OptionalServiceVersion{ + OptionalString: argparser.OptionalString{Value: "1"}, + }, + AutoClone: argparser.OptionalAutoClone{ + OptionalBool: argparser.OptionalBool{ + Optional: argparser.Optional{ + WasSet: true, + }, + Value: true, + }, + }, + } +} + +func createCommandAll() *logshuttle.CreateCommand { + var b bytes.Buffer + + g := global.Data{ + Config: config.File{}, + Env: config.Environment{}, + Output: &b, + } + g.APIClient, _ = mock.APIClient(mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + })("token", "endpoint", false) + + return &logshuttle.CreateCommand{ + Base: argparser.Base{ + Globals: &g, + }, + Manifest: manifest.Data{ + Flag: manifest.Flag{ + ServiceID: "123", + }, + }, + EndpointName: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "log"}, + Token: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "tkn"}, + URL: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "example.com"}, + ServiceVersion: argparser.OptionalServiceVersion{ + OptionalString: argparser.OptionalString{Value: "1"}, + }, + AutoClone: argparser.OptionalAutoClone{ + OptionalBool: argparser.OptionalBool{ + Optional: argparser.Optional{ + WasSet: true, + }, + Value: true, + }, + }, + Format: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: `%h %l %u %t "%r" %>s %b`}, + FormatVersion: argparser.OptionalInt{Optional: argparser.Optional{WasSet: true}, Value: 2}, + ResponseCondition: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "Prevent default logging"}, + Placement: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "none"}, + } +} + +func createCommandMissingServiceID() *logshuttle.CreateCommand { + res := createCommandAll() + res.Manifest = manifest.Data{} + return res +} + +func updateCommandNoUpdate() *logshuttle.UpdateCommand { + var b bytes.Buffer + + g := global.Data{ + Config: config.File{}, + Env: config.Environment{}, + Output: &b, + } + + return &logshuttle.UpdateCommand{ + Base: argparser.Base{ + Globals: &g, + }, + Manifest: manifest.Data{ + Flag: manifest.Flag{ + ServiceID: "123", + }, + }, + EndpointName: "log", + ServiceVersion: argparser.OptionalServiceVersion{ + OptionalString: argparser.OptionalString{Value: "1"}, + }, + AutoClone: argparser.OptionalAutoClone{ + OptionalBool: argparser.OptionalBool{ + Optional: argparser.Optional{ + WasSet: true, + }, + Value: true, + }, + }, + } +} + +func updateCommandAll() *logshuttle.UpdateCommand { + var b bytes.Buffer + + g := global.Data{ + Config: config.File{}, + Env: config.Environment{}, + Output: &b, + } + + return &logshuttle.UpdateCommand{ + Base: argparser.Base{ + Globals: &g, + }, + Manifest: manifest.Data{ + Flag: manifest.Flag{ + ServiceID: "123", + }, + }, + EndpointName: "log", + ServiceVersion: argparser.OptionalServiceVersion{ + OptionalString: argparser.OptionalString{Value: "1"}, + }, + AutoClone: argparser.OptionalAutoClone{ + OptionalBool: argparser.OptionalBool{ + Optional: argparser.Optional{ + WasSet: true, + }, + Value: true, + }, + }, + NewName: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new1"}, + Format: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new2"}, + FormatVersion: argparser.OptionalInt{Optional: argparser.Optional{WasSet: true}, Value: 3}, + Token: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new3"}, + URL: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new4"}, + ResponseCondition: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new5"}, + Placement: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new6"}, + } +} + +func updateCommandMissingServiceID() *logshuttle.UpdateCommand { + res := updateCommandAll() + res.Manifest = manifest.Data{} + return res +} diff --git a/pkg/commands/logging/logshuttle/root.go b/pkg/commands/logging/logshuttle/root.go new file mode 100644 index 000000000..c620806aa --- /dev/null +++ b/pkg/commands/logging/logshuttle/root.go @@ -0,0 +1,31 @@ +package logshuttle + +import ( + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" +) + +// RootCommand is the parent command for all subcommands in this package. +// It should be installed under the primary root command. +type RootCommand struct { + argparser.Base + // no flags +} + +// CommandName is the string to be used to invoke this command. +const CommandName = "logshuttle" + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent argparser.Registerer, g *global.Data) *RootCommand { + var c RootCommand + c.Globals = g + c.CmdClause = parent.Command(CommandName, "Manipulate Fastly service version Logshuttle logging endpoints") + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { + panic("unreachable") +} diff --git a/pkg/commands/logging/logshuttle/update.go b/pkg/commands/logging/logshuttle/update.go new file mode 100644 index 000000000..1dcb72e55 --- /dev/null +++ b/pkg/commands/logging/logshuttle/update.go @@ -0,0 +1,164 @@ +package logshuttle + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/logging/common" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/text" +) + +// UpdateCommand calls the Fastly API to update a Logshuttle logging endpoint. +type UpdateCommand struct { + argparser.Base + Manifest manifest.Data + + // Required. + EndpointName string // Can't shadow argparser.Base method Name(). + ServiceName argparser.OptionalServiceNameID + ServiceVersion argparser.OptionalServiceVersion + + // Optional. + AutoClone argparser.OptionalAutoClone + NewName argparser.OptionalString + Format argparser.OptionalString + FormatVersion argparser.OptionalInt + Token argparser.OptionalString + URL argparser.OptionalString + ResponseCondition argparser.OptionalString + Placement argparser.OptionalString +} + +// NewUpdateCommand returns a usable command registered under the parent. +func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { + c := UpdateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("update", "Update a Logshuttle logging endpoint on a Fastly service version") + + // Required. + c.CmdClause.Flag("name", "The name of the Logshuttle logging object").Short('n').Required().StringVar(&c.EndpointName) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.ServiceVersion.Value, + Required: true, + }) + + // Optional. + c.CmdClause.Flag("auth-token", "The data authentication token associated with this endpoint").Action(c.Token.Set).StringVar(&c.Token.Value) + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.AutoClone.Set, + Dst: &c.AutoClone.Value, + }) + common.Format(c.CmdClause, &c.Format) + common.FormatVersion(c.CmdClause, &c.FormatVersion) + c.CmdClause.Flag("new-name", "New name of the Logshuttle logging object").Action(c.NewName.Set).StringVar(&c.NewName.Value) + common.Placement(c.CmdClause, &c.Placement) + common.ResponseCondition(c.CmdClause, &c.ResponseCondition) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.ServiceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.ServiceName.Value, + }) + c.CmdClause.Flag("url", "Your Log Shuttle endpoint url").Action(c.URL.Set).StringVar(&c.URL.Value) + return &c +} + +// ConstructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *UpdateCommand) ConstructInput(serviceID string, serviceVersion int) (*fastly.UpdateLogshuttleInput, error) { + input := fastly.UpdateLogshuttleInput{ + ServiceID: serviceID, + ServiceVersion: serviceVersion, + Name: c.EndpointName, + } + + // Set new values if set by user. + if c.NewName.WasSet { + input.NewName = &c.NewName.Value + } + + if c.Format.WasSet { + input.Format = fastly.ToPointer(argparser.Content(c.Format.Value)) + } + + if c.FormatVersion.WasSet { + input.FormatVersion = &c.FormatVersion.Value + } + + if c.URL.WasSet { + input.URL = &c.URL.Value + } + + if c.Token.WasSet { + input.Token = &c.Token.Value + } + + if c.ResponseCondition.WasSet { + input.ResponseCondition = &c.ResponseCondition.Value + } + + if c.Placement.WasSet { + input.Placement = &c.Placement.Value + } + + return &input, nil +} + +// Exec invokes the application logic for the command. +func (c *UpdateCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.AutoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.ServiceName, + ServiceVersionFlag: c.ServiceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + input, err := c.ConstructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + logshuttle, err := c.Globals.APIClient.UpdateLogshuttle(input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + text.Success(out, + "Updated Logshuttle logging endpoint %s (service %s version %d)", + fastly.ToValue(logshuttle.Name), + fastly.ToValue(logshuttle.ServiceID), + fastly.ToValue(logshuttle.ServiceVersion), + ) + return nil +} diff --git a/pkg/commands/logging/newrelic/create.go b/pkg/commands/logging/newrelic/create.go new file mode 100644 index 000000000..dd3799f12 --- /dev/null +++ b/pkg/commands/logging/newrelic/create.go @@ -0,0 +1,156 @@ +package newrelic + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/logging/common" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// CreateCommand calls the Fastly API to create an appropriate resource. +type CreateCommand struct { + argparser.Base + + // Required. + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion + + // Optional. + autoClone argparser.OptionalAutoClone + format argparser.OptionalString + formatVersion argparser.OptionalInt + key argparser.OptionalString + name argparser.OptionalString + placement argparser.OptionalString + region argparser.OptionalString + responseCondition argparser.OptionalString +} + +// NewCreateCommand returns a usable command registered under the parent. +func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { + c := CreateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("create", "Create an New Relic logging endpoint attached to the specified service version").Alias("add") + + // Required. + c.CmdClause.Flag("name", "The name for the real-time logging configuration").Action(c.name.Set).StringVar(&c.name.Value) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.autoClone.Set, + Dst: &c.autoClone.Value, + }) + common.Format(c.CmdClause, &c.format) + common.FormatVersion(c.CmdClause, &c.formatVersion) + c.CmdClause.Flag("key", "The Insert API key from the Account page of your New Relic account").Action(c.key.Set).StringVar(&c.key.Value) + c.CmdClause.Flag("placement", "Where in the generated VCL the logging call should be placed").Action(c.placement.Set).StringVar(&c.placement.Value) + c.CmdClause.Flag("region", "The region to which to stream logs").Action(c.region.Set).StringVar(&c.region.Value) + c.CmdClause.Flag("response-condition", "The name of an existing condition in the configured endpoint").Action(c.responseCondition.Set).StringVar(&c.responseCondition.Value) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + + return &c +} + +// Exec invokes the application logic for the command. +func (c *CreateCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.autoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + input := c.constructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + + l, err := c.Globals.APIClient.CreateNewRelic(input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fastly.ToValue(serviceVersion.Number), + }) + return err + } + + text.Success(out, + "Created New Relic logging endpoint '%s' (service: %s, version: %d)", + fastly.ToValue(l.Name), + fastly.ToValue(l.ServiceID), + fastly.ToValue(l.ServiceVersion), + ) + return nil +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *CreateCommand) constructInput(serviceID string, serviceVersion int) *fastly.CreateNewRelicInput { + var input fastly.CreateNewRelicInput + + if c.name.WasSet { + input.Name = &c.name.Value + } + input.ServiceID = serviceID + input.ServiceVersion = serviceVersion + if c.key.WasSet { + input.Token = &c.key.Value + } + + if c.format.WasSet { + input.Format = fastly.ToPointer(argparser.Content(c.format.Value)) + } + + if c.formatVersion.WasSet { + input.FormatVersion = &c.formatVersion.Value + } + + if c.placement.WasSet { + input.Placement = &c.placement.Value + } + + if c.region.WasSet { + input.Region = &c.region.Value + } + + if c.responseCondition.WasSet { + input.ResponseCondition = &c.responseCondition.Value + } + + return &input +} diff --git a/pkg/commands/logging/newrelic/delete.go b/pkg/commands/logging/newrelic/delete.go new file mode 100644 index 000000000..eb6261f9e --- /dev/null +++ b/pkg/commands/logging/newrelic/delete.go @@ -0,0 +1,110 @@ +package newrelic + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// NewDeleteCommand returns a usable command registered under the parent. +func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { + c := DeleteCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("delete", "Delete the New Relic Logs logging object for a particular service and version").Alias("remove") + + // Required. + c.CmdClause.Flag("name", "The name for the real-time logging configuration to delete").Required().StringVar(&c.name) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.autoClone.Set, + Dst: &c.autoClone.Value, + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + + return &c +} + +// DeleteCommand calls the Fastly API to delete an appropriate resource. +type DeleteCommand struct { + argparser.Base + + autoClone argparser.OptionalAutoClone + name string + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion +} + +// Exec invokes the application logic for the command. +func (c *DeleteCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.autoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + input := c.constructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + + err = c.Globals.APIClient.DeleteNewRelic(input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fastly.ToValue(serviceVersion.Number), + }) + return err + } + + text.Success(out, "Deleted New Relic logging endpoint '%s' (service: %s, version: %d)", c.name, serviceID, fastly.ToValue(serviceVersion.Number)) + return nil +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *DeleteCommand) constructInput(serviceID string, serviceVersion int) *fastly.DeleteNewRelicInput { + var input fastly.DeleteNewRelicInput + + input.Name = c.name + input.ServiceID = serviceID + input.ServiceVersion = serviceVersion + + return &input +} diff --git a/pkg/commands/logging/newrelic/describe.go b/pkg/commands/logging/newrelic/describe.go new file mode 100644 index 000000000..1e5d71378 --- /dev/null +++ b/pkg/commands/logging/newrelic/describe.go @@ -0,0 +1,139 @@ +package newrelic + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// NewDescribeCommand returns a usable command registered under the parent. +func NewDescribeCommand(parent argparser.Registerer, g *global.Data) *DescribeCommand { + c := DescribeCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("describe", "Get the details of a New Relic Logs logging object for a particular service and version").Alias("get") + + // Required. + c.CmdClause.Flag("name", "The name for the real-time logging configuration").Required().StringVar(&c.name) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + + return &c +} + +// DescribeCommand calls the Fastly API to describe an appropriate resource. +type DescribeCommand struct { + argparser.Base + argparser.JSONOutput + + name string + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion +} + +// Exec invokes the application logic for the command. +func (c *DescribeCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fsterr.ServiceVersion(serviceVersion), + }) + return err + } + + input := c.constructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + + o, err := c.Globals.APIClient.GetNewRelic(input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fastly.ToValue(serviceVersion.Number), + }) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + return c.print(out, o) +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *DescribeCommand) constructInput(serviceID string, serviceVersion int) *fastly.GetNewRelicInput { + var input fastly.GetNewRelicInput + + input.Name = c.name + input.ServiceID = serviceID + input.ServiceVersion = serviceVersion + + return &input +} + +// print displays the information returned from the API. +func (c *DescribeCommand) print(out io.Writer, nr *fastly.NewRelic) error { + lines := text.Lines{ + "Format Version": fastly.ToValue(nr.FormatVersion), + "Format": fastly.ToValue(nr.Format), + "Name": fastly.ToValue(nr.Name), + "Placement": fastly.ToValue(nr.Placement), + "Region": fastly.ToValue(nr.Region), + "Response Condition": fastly.ToValue(nr.ResponseCondition), + "Service Version": fastly.ToValue(nr.ServiceVersion), + "Token": fastly.ToValue(nr.Token), + } + if nr.CreatedAt != nil { + lines["Created at"] = nr.CreatedAt + } + if nr.UpdatedAt != nil { + lines["Updated at"] = nr.UpdatedAt + } + if nr.DeletedAt != nil { + lines["Deleted at"] = nr.DeletedAt + } + + if !c.Globals.Verbose() { + lines["Service ID"] = fastly.ToValue(nr.ServiceID) + } + text.PrintLines(out, lines) + + return nil +} diff --git a/pkg/commands/logging/newrelic/doc.go b/pkg/commands/logging/newrelic/doc.go new file mode 100644 index 000000000..3cf645a3c --- /dev/null +++ b/pkg/commands/logging/newrelic/doc.go @@ -0,0 +1,3 @@ +// Package newrelic contains commands to inspect and manipulate NewRelic logging +// endpoints. +package newrelic diff --git a/pkg/commands/logging/newrelic/list.go b/pkg/commands/logging/newrelic/list.go new file mode 100644 index 000000000..593d6fd45 --- /dev/null +++ b/pkg/commands/logging/newrelic/list.go @@ -0,0 +1,157 @@ +package newrelic + +import ( + "fmt" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// NewListCommand returns a usable command registered under the parent. +func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { + c := ListCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("list", "List all of the New Relic Logs logging objects for a particular service and version") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + + return &c +} + +// ListCommand calls the Fastly API to list appropriate resources. +type ListCommand struct { + argparser.Base + argparser.JSONOutput + + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion +} + +// Exec invokes the application logic for the command. +func (c *ListCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fsterr.ServiceVersion(serviceVersion), + }) + return err + } + + input := c.constructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + + o, err := c.Globals.APIClient.ListNewRelic(input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": serviceVersion.Number, + }) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + if c.Globals.Verbose() { + c.printVerbose(out, fastly.ToValue(serviceVersion.Number), o) + } else { + err = c.printSummary(out, o) + if err != nil { + return err + } + } + return nil +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *ListCommand) constructInput(serviceID string, serviceVersion int) *fastly.ListNewRelicInput { + var input fastly.ListNewRelicInput + + input.ServiceID = serviceID + input.ServiceVersion = serviceVersion + + return &input +} + +// printVerbose displays the information returned from the API in a verbose +// format. +func (c *ListCommand) printVerbose(out io.Writer, serviceVersion int, ls []*fastly.NewRelic) { + fmt.Fprintf(out, "Service Version: %d\n", serviceVersion) + + for _, l := range ls { + fmt.Fprintf(out, "\nName: %s\n", fastly.ToValue(l.Name)) + fmt.Fprintf(out, "\nToken: %s\n", fastly.ToValue(l.Token)) + fmt.Fprintf(out, "\nFormat: %s\n", fastly.ToValue(l.Format)) + fmt.Fprintf(out, "\nFormat Version: %d\n", fastly.ToValue(l.FormatVersion)) + fmt.Fprintf(out, "\nPlacement: %s\n", fastly.ToValue(l.Placement)) + fmt.Fprintf(out, "\nRegion: %s\n", fastly.ToValue(l.Region)) + fmt.Fprintf(out, "\nResponse Condition: %s\n\n", fastly.ToValue(l.ResponseCondition)) + + if l.CreatedAt != nil { + fmt.Fprintf(out, "Created at: %s\n", l.CreatedAt) + } + if l.UpdatedAt != nil { + fmt.Fprintf(out, "Updated at: %s\n", l.UpdatedAt) + } + if l.DeletedAt != nil { + fmt.Fprintf(out, "Deleted at: %s\n", l.DeletedAt) + } + } +} + +// printSummary displays the information returned from the API in a summarised +// format. +func (c *ListCommand) printSummary(out io.Writer, nrs []*fastly.NewRelic) error { + t := text.NewTable(out) + t.AddHeader("SERVICE ID", "VERSION", "NAME") + for _, nr := range nrs { + t.AddLine( + fastly.ToValue(nr.ServiceID), + fastly.ToValue(nr.ServiceVersion), + fastly.ToValue(nr.Name), + ) + } + t.Print() + return nil +} diff --git a/pkg/commands/logging/newrelic/newrelic_test.go b/pkg/commands/logging/newrelic/newrelic_test.go new file mode 100644 index 000000000..1cf697709 --- /dev/null +++ b/pkg/commands/logging/newrelic/newrelic_test.go @@ -0,0 +1,379 @@ +package newrelic_test + +import ( + "testing" + + "github.com/fastly/go-fastly/v10/fastly" + + root "github.com/fastly/cli/pkg/commands/logging" + sub "github.com/fastly/cli/pkg/commands/logging/newrelic" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" +) + +func TestNewRelicCreate(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --service-id flag", + Args: "--key abc --name foo --version 3", + WantError: "error reading service: no service ID found", + }, + { + Name: "validate missing --autoclone flag with 'active' service", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + }, + Args: "--key abc --name foo --service-id 123 --version 1", + WantError: "service version 1 is active", + }, + { + Name: "validate missing --autoclone flag with 'locked' service", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + }, + Args: "--key abc --name foo --service-id 123 --version 2", + WantError: "service version 2 is locked", + }, + { + Name: "validate CreateNewRelic API error", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + CreateNewRelicFn: func(_ *fastly.CreateNewRelicInput) (*fastly.NewRelic, error) { + return nil, testutil.Err + }, + }, + Args: "--key abc --name foo --service-id 123 --version 3", + WantError: testutil.Err.Error(), + }, + { + Name: "validate CreateNewRelic API success", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + CreateNewRelicFn: func(i *fastly.CreateNewRelicInput) (*fastly.NewRelic, error) { + return &fastly.NewRelic{ + Name: i.Name, + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + }, nil + }, + }, + Args: "--key abc --name foo --service-id 123 --version 3", + WantOutput: "Created New Relic logging endpoint 'foo' (service: 123, version: 3)", + }, + { + Name: "validate --autoclone results in cloned service version", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + CreateNewRelicFn: func(i *fastly.CreateNewRelicInput) (*fastly.NewRelic, error) { + return &fastly.NewRelic{ + Name: i.Name, + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + }, nil + }, + }, + Args: "--autoclone --key abc --name foo --service-id 123 --version 1", + WantOutput: "Created New Relic logging endpoint 'foo' (service: 123, version: 4)", + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "create"}, scenarios) +} + +func TestNewRelicDelete(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --name flag", + Args: "--version 3", + WantError: "error parsing arguments: required flag --name not provided", + }, + { + Name: "validate missing --version flag", + Args: "--name foobar", + WantError: "error parsing arguments: required flag --version not provided", + }, + { + Name: "validate missing --service-id flag", + Args: "--name foobar --version 3", + WantError: "error reading service: no service ID found", + }, + { + Name: "validate missing --autoclone flag with 'active' service", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + }, + Args: "--name foobar --service-id 123 --version 1", + WantError: "service version 1 is active", + }, + { + Name: "validate missing --autoclone flag with 'locked' service", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + }, + Args: "--name foobar --service-id 123 --version 2", + WantError: "service version 2 is locked", + }, + { + Name: "validate DeleteNewRelic API error", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + DeleteNewRelicFn: func(_ *fastly.DeleteNewRelicInput) error { + return testutil.Err + }, + }, + Args: "--name foobar --service-id 123 --version 3", + WantError: testutil.Err.Error(), + }, + { + Name: "validate DeleteNewRelic API success", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + DeleteNewRelicFn: func(_ *fastly.DeleteNewRelicInput) error { + return nil + }, + }, + Args: "--name foobar --service-id 123 --version 3", + WantOutput: "Deleted New Relic logging endpoint 'foobar' (service: 123, version: 3)", + }, + { + Name: "validate --autoclone results in cloned service version", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + DeleteNewRelicFn: func(_ *fastly.DeleteNewRelicInput) error { + return nil + }, + }, + Args: "--autoclone --name foo --service-id 123 --version 1", + WantOutput: "Deleted New Relic logging endpoint 'foo' (service: 123, version: 4)", + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "delete"}, scenarios) +} + +func TestNewRelicDescribe(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --name flag", + Args: "--version 3", + WantError: "error parsing arguments: required flag --name not provided", + }, + { + Name: "validate missing --version flag", + Args: "--name foobar", + WantError: "error parsing arguments: required flag --version not provided", + }, + { + Name: "validate missing --service-id flag", + Args: "--name foobar --version 3", + WantError: "error reading service: no service ID found", + }, + { + Name: "validate GetNewRelic API error", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + GetNewRelicFn: func(_ *fastly.GetNewRelicInput) (*fastly.NewRelic, error) { + return nil, testutil.Err + }, + }, + Args: "--name foobar --service-id 123 --version 3", + WantError: testutil.Err.Error(), + }, + { + Name: "validate GetNewRelic API success", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + GetNewRelicFn: getNewRelic, + }, + Args: "--name foobar --service-id 123 --version 3", + WantOutput: "\nCreated at: 2021-06-15 23:00:00 +0000 UTC\nDeleted at: 2021-06-15 23:00:00 +0000 UTC\nFormat: \nFormat Version: 0\nName: foobar\nPlacement: \nRegion: \nResponse Condition: \nService ID: 123\nService Version: 3\nToken: abc\nUpdated at: 2021-06-15 23:00:00 +0000 UTC\n", + }, + { + Name: "validate missing --autoclone flag is OK", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + GetNewRelicFn: getNewRelic, + }, + Args: "--name foobar --service-id 123 --version 1", + WantOutput: "\nCreated at: 2021-06-15 23:00:00 +0000 UTC\nDeleted at: 2021-06-15 23:00:00 +0000 UTC\nFormat: \nFormat Version: 0\nName: foobar\nPlacement: \nRegion: \nResponse Condition: \nService ID: 123\nService Version: 1\nToken: abc\nUpdated at: 2021-06-15 23:00:00 +0000 UTC\n", + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "describe"}, scenarios) +} + +func TestNewRelicList(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --version flag", + WantError: "error parsing arguments: required flag --version not provided", + }, + { + Name: "validate missing --service-id flag", + Args: "--version 3", + WantError: "error reading service: no service ID found", + }, + { + Name: "validate ListNewRelics API error", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListNewRelicFn: func(_ *fastly.ListNewRelicInput) ([]*fastly.NewRelic, error) { + return nil, testutil.Err + }, + }, + Args: "--service-id 123 --version 3", + WantError: testutil.Err.Error(), + }, + { + Name: "validate ListNewRelics API success", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListNewRelicFn: listNewRelic, + }, + Args: "--service-id 123 --version 3", + WantOutput: "SERVICE ID VERSION NAME\n123 3 foo\n123 3 bar\n", + }, + { + Name: "validate missing --autoclone flag is OK", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListNewRelicFn: listNewRelic, + }, + Args: "--service-id 123 --version 1", + WantOutput: "SERVICE ID VERSION NAME\n123 1 foo\n123 1 bar\n", + }, + { + Name: "validate missing --verbose flag", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListNewRelicFn: listNewRelic, + }, + Args: "--service-id 123 --verbose --version 1", + WantOutput: "Fastly API endpoint: https://api.fastly.com\nFastly API token provided via config file (profile: user)\n\nService ID (via --service-id): 123\n\nService Version: 1\n\nName: foo\n\nToken: \n\nFormat: \n\nFormat Version: 0\n\nPlacement: \n\nRegion: \n\nResponse Condition: \n\nCreated at: 2021-06-15 23:00:00 +0000 UTC\nUpdated at: 2021-06-15 23:00:00 +0000 UTC\nDeleted at: 2021-06-15 23:00:00 +0000 UTC\n\nName: bar\n\nToken: \n\nFormat: \n\nFormat Version: 0\n\nPlacement: \n\nRegion: \n\nResponse Condition: \n\nCreated at: 2021-06-15 23:00:00 +0000 UTC\nUpdated at: 2021-06-15 23:00:00 +0000 UTC\nDeleted at: 2021-06-15 23:00:00 +0000 UTC\n", + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "list"}, scenarios) +} + +func TestNewRelicUpdate(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --name flag", + Args: "--service-id 123 --version 3", + WantError: "error parsing arguments: required flag --name not provided", + }, + { + Name: "validate missing --version flag", + Args: "--name foobar --service-id 123", + WantError: "error parsing arguments: required flag --version not provided", + }, + { + Name: "validate missing --service-id flag", + Args: "--name foobar --version 3", + WantError: "error reading service: no service ID found", + }, + { + Name: "validate missing --autoclone flag with 'active' service", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + }, + Args: "--name foobar --service-id 123 --version 1", + WantError: "service version 1 is active", + }, + { + Name: "validate missing --autoclone flag with 'locked' service", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + }, + Args: "--name foobar --service-id 123 --version 2", + WantError: "service version 2 is locked", + }, + { + Name: "validate UpdateNewRelic API error", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + UpdateNewRelicFn: func(_ *fastly.UpdateNewRelicInput) (*fastly.NewRelic, error) { + return nil, testutil.Err + }, + }, + Args: "--name foobar --new-name beepboop --service-id 123 --version 3", + WantError: testutil.Err.Error(), + }, + { + Name: "validate UpdateNewRelic API success", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + UpdateNewRelicFn: func(i *fastly.UpdateNewRelicInput) (*fastly.NewRelic, error) { + return &fastly.NewRelic{ + Name: i.NewName, + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + }, nil + }, + }, + Args: "--name foobar --new-name beepboop --service-id 123 --version 3", + WantOutput: "Updated New Relic logging endpoint 'beepboop' (previously: foobar, service: 123, version: 3)", + }, + { + Name: "validate --autoclone results in cloned service version", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + UpdateNewRelicFn: func(i *fastly.UpdateNewRelicInput) (*fastly.NewRelic, error) { + return &fastly.NewRelic{ + Name: i.NewName, + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + }, nil + }, + }, + Args: "--autoclone --name foobar --new-name beepboop --service-id 123 --version 1", + WantOutput: "Updated New Relic logging endpoint 'beepboop' (previously: foobar, service: 123, version: 4)", + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "update"}, scenarios) +} + +func getNewRelic(i *fastly.GetNewRelicInput) (*fastly.NewRelic, error) { + t := testutil.Date + + return &fastly.NewRelic{ + Name: fastly.ToPointer(i.Name), + Token: fastly.ToPointer("abc"), + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + + CreatedAt: &t, + DeletedAt: &t, + UpdatedAt: &t, + }, nil +} + +func listNewRelic(i *fastly.ListNewRelicInput) ([]*fastly.NewRelic, error) { + t := testutil.Date + vs := []*fastly.NewRelic{ + { + Name: fastly.ToPointer("foo"), + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + + CreatedAt: &t, + DeletedAt: &t, + UpdatedAt: &t, + }, + { + Name: fastly.ToPointer("bar"), + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + + CreatedAt: &t, + DeletedAt: &t, + UpdatedAt: &t, + }, + } + return vs, nil +} diff --git a/pkg/commands/logging/newrelic/root.go b/pkg/commands/logging/newrelic/root.go new file mode 100644 index 000000000..5fbf26759 --- /dev/null +++ b/pkg/commands/logging/newrelic/root.go @@ -0,0 +1,31 @@ +package newrelic + +import ( + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" +) + +// RootCommand is the parent command for all subcommands in this package. +// It should be installed under the primary root command. +type RootCommand struct { + argparser.Base + // no flags +} + +// CommandName is the string to be used to invoke this command. +const CommandName = "newrelic" + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent argparser.Registerer, g *global.Data) *RootCommand { + var c RootCommand + c.Globals = g + c.CmdClause = parent.Command(CommandName, "Manipulate a NewRelic logging endpoint for a specific Fastly service version") + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { + panic("unreachable") +} diff --git a/pkg/commands/logging/newrelic/update.go b/pkg/commands/logging/newrelic/update.go new file mode 100644 index 000000000..5fac88ae5 --- /dev/null +++ b/pkg/commands/logging/newrelic/update.go @@ -0,0 +1,160 @@ +package newrelic + +import ( + "fmt" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/logging/common" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// UpdateCommand calls the Fastly API to update an appropriate resource. +type UpdateCommand struct { + argparser.Base + + endpointName string + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion + + autoClone argparser.OptionalAutoClone + format argparser.OptionalString + formatVersion argparser.OptionalInt + key argparser.OptionalString + newName argparser.OptionalString + placement argparser.OptionalString + region argparser.OptionalString + responseCondition argparser.OptionalString +} + +// NewUpdateCommand returns a usable command registered under the parent. +func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { + c := UpdateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("update", "Update a New Relic Logs logging object for a particular service and version") + + // Required. + c.CmdClause.Flag("name", "The name for the real-time logging configuration to update").Required().StringVar(&c.endpointName) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.autoClone.Set, + Dst: &c.autoClone.Value, + }) + common.Format(c.CmdClause, &c.format) + c.CmdClause.Flag("format-version", "The version of the custom logging format used for the configured endpoint").Action(c.formatVersion.Set).IntVar(&c.formatVersion.Value) + c.CmdClause.Flag("key", "The Insert API key from the Account page of your New Relic account").Action(c.key.Set).StringVar(&c.key.Value) + c.CmdClause.Flag("new-name", "The name for the real-time logging configuration").Action(c.newName.Set).StringVar(&c.newName.Value) + c.CmdClause.Flag("placement", "Where in the generated VCL the logging call should be placed").Action(c.placement.Set).StringVar(&c.placement.Value) + c.CmdClause.Flag("region", "The region to which to stream logs").Action(c.region.Set).StringVar(&c.region.Value) + c.CmdClause.Flag("response-condition", "The name of an existing condition in the configured endpoint").Action(c.responseCondition.Set).StringVar(&c.responseCondition.Value) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + + return &c +} + +// Exec invokes the application logic for the command. +func (c *UpdateCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.autoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + input := c.constructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + + l, err := c.Globals.APIClient.UpdateNewRelic(input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fastly.ToValue(serviceVersion.Number), + }) + return err + } + + var prev string + if c.newName.WasSet { + prev = fmt.Sprintf("previously: %s, ", c.endpointName) + } + + text.Success(out, + "Updated New Relic logging endpoint '%s' (%sservice: %s, version: %d)", + fastly.ToValue(l.Name), + prev, + fastly.ToValue(l.ServiceID), + fastly.ToValue(l.ServiceVersion), + ) + return nil +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *UpdateCommand) constructInput(serviceID string, serviceVersion int) *fastly.UpdateNewRelicInput { + var input fastly.UpdateNewRelicInput + + input.Name = c.endpointName + input.ServiceID = serviceID + input.ServiceVersion = serviceVersion + + if c.format.WasSet { + input.Format = fastly.ToPointer(argparser.Content(c.format.Value)) + } + if c.formatVersion.WasSet { + input.FormatVersion = &c.formatVersion.Value + } + if c.key.WasSet { + input.Token = &c.key.Value + } + if c.newName.WasSet { + input.NewName = &c.newName.Value + } + if c.placement.WasSet { + input.Placement = &c.placement.Value + } + if c.region.WasSet { + input.Region = &c.region.Value + } + if c.responseCondition.WasSet { + input.ResponseCondition = &c.responseCondition.Value + } + + return &input +} diff --git a/pkg/commands/logging/newrelicotlp/create.go b/pkg/commands/logging/newrelicotlp/create.go new file mode 100644 index 000000000..5042e1050 --- /dev/null +++ b/pkg/commands/logging/newrelicotlp/create.go @@ -0,0 +1,162 @@ +package newrelicotlp + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/logging/common" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// CreateCommand calls the Fastly API to create an appropriate resource. +type CreateCommand struct { + argparser.Base + + // Required. + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion + + // Optional. + autoClone argparser.OptionalAutoClone + format argparser.OptionalString + formatVersion argparser.OptionalInt + key argparser.OptionalString + name argparser.OptionalString + placement argparser.OptionalString + region argparser.OptionalString + responseCondition argparser.OptionalString + url argparser.OptionalString +} + +// NewCreateCommand returns a usable command registered under the parent. +func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { + c := CreateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("create", "Create an New Relic logging endpoint attached to the specified service version").Alias("add") + + // Required. + c.CmdClause.Flag("name", "The name for the real-time logging configuration").Action(c.name.Set).StringVar(&c.name.Value) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.autoClone.Set, + Dst: &c.autoClone.Value, + }) + common.Format(c.CmdClause, &c.format) + common.FormatVersion(c.CmdClause, &c.formatVersion) + c.CmdClause.Flag("key", "The Insert API key from the Account page of your New Relic account").Action(c.key.Set).StringVar(&c.key.Value) + c.CmdClause.Flag("placement", "Where in the generated VCL the logging call should be placed").Action(c.placement.Set).StringVar(&c.placement.Value) + c.CmdClause.Flag("region", "The region to which to stream logs").Action(c.region.Set).StringVar(&c.region.Value) + c.CmdClause.Flag("response-condition", "The name of an existing condition in the configured endpoint").Action(c.responseCondition.Set).StringVar(&c.responseCondition.Value) + c.CmdClause.Flag("url", "URL of the New Relic Trace Observer, if you are using New Relic Infinite Tracing").Action(c.url.Set).StringVar(&c.url.Value) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + + return &c +} + +// Exec invokes the application logic for the command. +func (c *CreateCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.autoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + input := c.constructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + + l, err := c.Globals.APIClient.CreateNewRelicOTLP(input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": serviceVersion.Number, + }) + return err + } + + text.Success(out, + "Created New Relic OTLP logging endpoint '%s' (service: %s, version: %d)", + fastly.ToValue(l.Name), + fastly.ToValue(l.ServiceID), + fastly.ToValue(l.ServiceVersion), + ) + return nil +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *CreateCommand) constructInput(serviceID string, serviceVersion int) *fastly.CreateNewRelicOTLPInput { + var input fastly.CreateNewRelicOTLPInput + + if c.name.WasSet { + input.Name = &c.name.Value + } + input.ServiceID = serviceID + input.ServiceVersion = serviceVersion + if c.key.WasSet { + input.Token = &c.key.Value + } + + if c.format.WasSet { + input.Format = fastly.ToPointer(argparser.Content(c.format.Value)) + } + + if c.formatVersion.WasSet { + input.FormatVersion = &c.formatVersion.Value + } + + if c.placement.WasSet { + input.Placement = &c.placement.Value + } + + if c.region.WasSet { + input.Region = &c.region.Value + } + + if c.responseCondition.WasSet { + input.ResponseCondition = &c.responseCondition.Value + } + + if c.url.WasSet { + input.URL = &c.url.Value + } + + return &input +} diff --git a/pkg/commands/logging/newrelicotlp/delete.go b/pkg/commands/logging/newrelicotlp/delete.go new file mode 100644 index 000000000..160854a37 --- /dev/null +++ b/pkg/commands/logging/newrelicotlp/delete.go @@ -0,0 +1,110 @@ +package newrelicotlp + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// NewDeleteCommand returns a usable command registered under the parent. +func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { + c := DeleteCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("delete", "Delete the New Relic OTLP Logs logging object for a particular service and version").Alias("remove") + + // Required. + c.CmdClause.Flag("name", "The name for the real-time logging configuration to delete").Required().StringVar(&c.name) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.autoClone.Set, + Dst: &c.autoClone.Value, + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + + return &c +} + +// DeleteCommand calls the Fastly API to delete an appropriate resource. +type DeleteCommand struct { + argparser.Base + + autoClone argparser.OptionalAutoClone + name string + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion +} + +// Exec invokes the application logic for the command. +func (c *DeleteCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.autoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + input := c.constructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + + err = c.Globals.APIClient.DeleteNewRelicOTLP(input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fastly.ToValue(serviceVersion.Number), + }) + return err + } + + text.Success(out, "Deleted New Relic OTLP logging endpoint '%s' (service: %s, version: %d)", c.name, serviceID, fastly.ToValue(serviceVersion.Number)) + return nil +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *DeleteCommand) constructInput(serviceID string, serviceVersion int) *fastly.DeleteNewRelicOTLPInput { + var input fastly.DeleteNewRelicOTLPInput + + input.Name = c.name + input.ServiceID = serviceID + input.ServiceVersion = serviceVersion + + return &input +} diff --git a/pkg/commands/logging/newrelicotlp/describe.go b/pkg/commands/logging/newrelicotlp/describe.go new file mode 100644 index 000000000..c79c4f3bd --- /dev/null +++ b/pkg/commands/logging/newrelicotlp/describe.go @@ -0,0 +1,140 @@ +package newrelicotlp + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// NewDescribeCommand returns a usable command registered under the parent. +func NewDescribeCommand(parent argparser.Registerer, g *global.Data) *DescribeCommand { + c := DescribeCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("describe", "Get the details of a New Relic OTLP Logs logging object for a particular service and version").Alias("get") + + // Required. + c.CmdClause.Flag("name", "The name for the real-time logging configuration").Required().StringVar(&c.name) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + + return &c +} + +// DescribeCommand calls the Fastly API to describe an appropriate resource. +type DescribeCommand struct { + argparser.Base + argparser.JSONOutput + + name string + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion +} + +// Exec invokes the application logic for the command. +func (c *DescribeCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fsterr.ServiceVersion(serviceVersion), + }) + return err + } + + input := c.constructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + + o, err := c.Globals.APIClient.GetNewRelicOTLP(input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fastly.ToValue(serviceVersion.Number), + }) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + return c.print(out, o) +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *DescribeCommand) constructInput(serviceID string, serviceVersion int) *fastly.GetNewRelicOTLPInput { + var input fastly.GetNewRelicOTLPInput + + input.Name = c.name + input.ServiceID = serviceID + input.ServiceVersion = serviceVersion + + return &input +} + +// print displays the information returned from the API. +func (c *DescribeCommand) print(out io.Writer, nr *fastly.NewRelicOTLP) error { + lines := text.Lines{ + "Format Version": fastly.ToValue(nr.FormatVersion), + "Format": fastly.ToValue(nr.Format), + "Name": fastly.ToValue(nr.Name), + "Placement": fastly.ToValue(nr.Placement), + "Region": fastly.ToValue(nr.Region), + "Response Condition": fastly.ToValue(nr.ResponseCondition), + "Service Version": fastly.ToValue(nr.ServiceVersion), + "Token": fastly.ToValue(nr.Token), + "URL": fastly.ToValue(nr.URL), + } + if nr.CreatedAt != nil { + lines["Created at"] = nr.CreatedAt + } + if nr.UpdatedAt != nil { + lines["Updated at"] = nr.UpdatedAt + } + if nr.DeletedAt != nil { + lines["Deleted at"] = nr.DeletedAt + } + + if !c.Globals.Verbose() { + lines["Service ID"] = fastly.ToValue(nr.ServiceID) + } + text.PrintLines(out, lines) + + return nil +} diff --git a/pkg/commands/logging/newrelicotlp/doc.go b/pkg/commands/logging/newrelicotlp/doc.go new file mode 100644 index 000000000..f65965183 --- /dev/null +++ b/pkg/commands/logging/newrelicotlp/doc.go @@ -0,0 +1,3 @@ +// Package newrelicotlp contains commands to inspect and manipulate NewRelicOTLP logging +// endpoints. +package newrelicotlp diff --git a/pkg/commands/logging/newrelicotlp/list.go b/pkg/commands/logging/newrelicotlp/list.go new file mode 100644 index 000000000..a6a0901ba --- /dev/null +++ b/pkg/commands/logging/newrelicotlp/list.go @@ -0,0 +1,157 @@ +package newrelicotlp + +import ( + "fmt" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// NewListCommand returns a usable command registered under the parent. +func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { + c := ListCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("list", "List all of the New Relic OTLP Logs logging objects for a particular service and version") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + + return &c +} + +// ListCommand calls the Fastly API to list appropriate resources. +type ListCommand struct { + argparser.Base + argparser.JSONOutput + + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion +} + +// Exec invokes the application logic for the command. +func (c *ListCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fsterr.ServiceVersion(serviceVersion), + }) + return err + } + + input := c.constructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + + o, err := c.Globals.APIClient.ListNewRelicOTLP(input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fastly.ToValue(serviceVersion.Number), + }) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + if c.Globals.Verbose() { + c.printVerbose(out, fastly.ToValue(serviceVersion.Number), o) + } else { + err = c.printSummary(out, o) + if err != nil { + return err + } + } + return nil +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *ListCommand) constructInput(serviceID string, serviceVersion int) *fastly.ListNewRelicOTLPInput { + var input fastly.ListNewRelicOTLPInput + + input.ServiceID = serviceID + input.ServiceVersion = serviceVersion + + return &input +} + +// printVerbose displays the information returned from the API in a verbose +// format. +func (c *ListCommand) printVerbose(out io.Writer, serviceVersion int, ls []*fastly.NewRelicOTLP) { + fmt.Fprintf(out, "Service Version: %d\n", serviceVersion) + + for _, l := range ls { + fmt.Fprintf(out, "\nName: %s\n", fastly.ToValue(l.Name)) + fmt.Fprintf(out, "\nToken: %s\n", fastly.ToValue(l.Token)) + fmt.Fprintf(out, "\nFormat: %s\n", fastly.ToValue(l.Format)) + fmt.Fprintf(out, "\nFormat Version: %d\n", fastly.ToValue(l.FormatVersion)) + fmt.Fprintf(out, "\nPlacement: %s\n", fastly.ToValue(l.Placement)) + fmt.Fprintf(out, "\nRegion: %s\n", fastly.ToValue(l.Region)) + fmt.Fprintf(out, "\nResponse Condition: %s\n\n", fastly.ToValue(l.ResponseCondition)) + + if l.CreatedAt != nil { + fmt.Fprintf(out, "Created at: %s\n", l.CreatedAt) + } + if l.UpdatedAt != nil { + fmt.Fprintf(out, "Updated at: %s\n", l.UpdatedAt) + } + if l.DeletedAt != nil { + fmt.Fprintf(out, "Deleted at: %s\n", l.DeletedAt) + } + } +} + +// printSummary displays the information returned from the API in a summarised +// format. +func (c *ListCommand) printSummary(out io.Writer, nrs []*fastly.NewRelicOTLP) error { + t := text.NewTable(out) + t.AddHeader("SERVICE ID", "VERSION", "NAME") + for _, nr := range nrs { + t.AddLine( + fastly.ToValue(nr.ServiceID), + fastly.ToValue(nr.ServiceVersion), + fastly.ToValue(nr.Name), + ) + } + t.Print() + return nil +} diff --git a/pkg/commands/logging/newrelicotlp/newrelicotlp_test.go b/pkg/commands/logging/newrelicotlp/newrelicotlp_test.go new file mode 100644 index 000000000..1da8aa870 --- /dev/null +++ b/pkg/commands/logging/newrelicotlp/newrelicotlp_test.go @@ -0,0 +1,379 @@ +package newrelicotlp_test + +import ( + "testing" + + "github.com/fastly/go-fastly/v10/fastly" + + root "github.com/fastly/cli/pkg/commands/logging" + sub "github.com/fastly/cli/pkg/commands/logging/newrelicotlp" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" +) + +func TestNewRelicOTLPCreate(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --service-id flag", + Args: "--key abc --name foo --version 3", + WantError: "error reading service: no service ID found", + }, + { + Name: "validate missing --autoclone flag with 'active' service", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + }, + Args: "--key abc --name foo --service-id 123 --version 1", + WantError: "service version 1 is active", + }, + { + Name: "validate missing --autoclone flag with 'locked' service", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + }, + Args: "--key abc --name foo --service-id 123 --version 2", + WantError: "service version 2 is locked", + }, + { + Name: "validate CreateNewRelicOTLP API error", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + CreateNewRelicOTLPFn: func(_ *fastly.CreateNewRelicOTLPInput) (*fastly.NewRelicOTLP, error) { + return nil, testutil.Err + }, + }, + Args: "--key abc --name foo --service-id 123 --version 3", + WantError: testutil.Err.Error(), + }, + { + Name: "validate CreateNewRelicOTLP API success", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + CreateNewRelicOTLPFn: func(i *fastly.CreateNewRelicOTLPInput) (*fastly.NewRelicOTLP, error) { + return &fastly.NewRelicOTLP{ + Name: i.Name, + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + }, nil + }, + }, + Args: "--key abc --name foo --service-id 123 --version 3", + WantOutput: "Created New Relic OTLP logging endpoint 'foo' (service: 123, version: 3)", + }, + { + Name: "validate --autoclone results in cloned service version", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + CreateNewRelicOTLPFn: func(i *fastly.CreateNewRelicOTLPInput) (*fastly.NewRelicOTLP, error) { + return &fastly.NewRelicOTLP{ + Name: i.Name, + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + }, nil + }, + }, + Args: "--autoclone --key abc --name foo --service-id 123 --version 1", + WantOutput: "Created New Relic OTLP logging endpoint 'foo' (service: 123, version: 4)", + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "create"}, scenarios) +} + +func TestNewRelicOTLPDelete(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --name flag", + Args: "--version 3", + WantError: "error parsing arguments: required flag --name not provided", + }, + { + Name: "validate missing --version flag", + Args: "--name foobar", + WantError: "error parsing arguments: required flag --version not provided", + }, + { + Name: "validate missing --service-id flag", + Args: "--name foobar --version 3", + WantError: "error reading service: no service ID found", + }, + { + Name: "validate missing --autoclone flag with 'active' service", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + }, + Args: "--name foobar --service-id 123 --version 1", + WantError: "service version 1 is active", + }, + { + Name: "validate missing --autoclone flag with 'locked' service", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + }, + Args: "--name foobar --service-id 123 --version 2", + WantError: "service version 2 is locked", + }, + { + Name: "validate DeleteNewRelic API error", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + DeleteNewRelicOTLPFn: func(_ *fastly.DeleteNewRelicOTLPInput) error { + return testutil.Err + }, + }, + Args: "--name foobar --service-id 123 --version 3", + WantError: testutil.Err.Error(), + }, + { + Name: "validate DeleteNewRelic API success", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + DeleteNewRelicOTLPFn: func(_ *fastly.DeleteNewRelicOTLPInput) error { + return nil + }, + }, + Args: "--name foobar --service-id 123 --version 3", + WantOutput: "Deleted New Relic OTLP logging endpoint 'foobar' (service: 123, version: 3)", + }, + { + Name: "validate --autoclone results in cloned service version", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + DeleteNewRelicOTLPFn: func(_ *fastly.DeleteNewRelicOTLPInput) error { + return nil + }, + }, + Args: "--autoclone --name foo --service-id 123 --version 1", + WantOutput: "Deleted New Relic OTLP logging endpoint 'foo' (service: 123, version: 4)", + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "delete"}, scenarios) +} + +func TestNewRelicDescribe(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --name flag", + Args: "--version 3", + WantError: "error parsing arguments: required flag --name not provided", + }, + { + Name: "validate missing --version flag", + Args: "--name foobar", + WantError: "error parsing arguments: required flag --version not provided", + }, + { + Name: "validate missing --service-id flag", + Args: "--name foobar --version 3", + WantError: "error reading service: no service ID found", + }, + { + Name: "validate GetNewRelic API error", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + GetNewRelicOTLPFn: func(_ *fastly.GetNewRelicOTLPInput) (*fastly.NewRelicOTLP, error) { + return nil, testutil.Err + }, + }, + Args: "--name foobar --service-id 123 --version 3", + WantError: testutil.Err.Error(), + }, + { + Name: "validate GetNewRelic API success", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + GetNewRelicOTLPFn: getNewRelic, + }, + Args: "--name foobar --service-id 123 --version 3", + WantOutput: "\nCreated at: 2021-06-15 23:00:00 +0000 UTC\nDeleted at: 2021-06-15 23:00:00 +0000 UTC\nFormat: \nFormat Version: 0\nName: foobar\nPlacement: \nRegion: \nResponse Condition: \nService ID: 123\nService Version: 3\nToken: abc\nURL: \nUpdated at: 2021-06-15 23:00:00 +0000 UTC\n", + }, + { + Name: "validate missing --autoclone flag is OK", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + GetNewRelicOTLPFn: getNewRelic, + }, + Args: "--name foobar --service-id 123 --version 1", + WantOutput: "\nCreated at: 2021-06-15 23:00:00 +0000 UTC\nDeleted at: 2021-06-15 23:00:00 +0000 UTC\nFormat: \nFormat Version: 0\nName: foobar\nPlacement: \nRegion: \nResponse Condition: \nService ID: 123\nService Version: 1\nToken: abc\nURL: \nUpdated at: 2021-06-15 23:00:00 +0000 UTC\n", + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "describe"}, scenarios) +} + +func TestNewRelicList(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --version flag", + WantError: "error parsing arguments: required flag --version not provided", + }, + { + Name: "validate missing --service-id flag", + Args: "--version 3", + WantError: "error reading service: no service ID found", + }, + { + Name: "validate ListNewRelics API error", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListNewRelicOTLPFn: func(_ *fastly.ListNewRelicOTLPInput) ([]*fastly.NewRelicOTLP, error) { + return nil, testutil.Err + }, + }, + Args: "--service-id 123 --version 3", + WantError: testutil.Err.Error(), + }, + { + Name: "validate ListNewRelics API success", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListNewRelicOTLPFn: listNewRelic, + }, + Args: "--service-id 123 --version 3", + WantOutput: "SERVICE ID VERSION NAME\n123 3 foo\n123 3 bar\n", + }, + { + Name: "validate missing --autoclone flag is OK", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListNewRelicOTLPFn: listNewRelic, + }, + Args: "--service-id 123 --version 1", + WantOutput: "SERVICE ID VERSION NAME\n123 1 foo\n123 1 bar\n", + }, + { + Name: "validate missing --verbose flag", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListNewRelicOTLPFn: listNewRelic, + }, + Args: "--service-id 123 --verbose --version 1", + WantOutput: "Fastly API endpoint: https://api.fastly.com\nFastly API token provided via config file (profile: user)\n\nService ID (via --service-id): 123\n\nService Version: 1\n\nName: foo\n\nToken: \n\nFormat: \n\nFormat Version: 0\n\nPlacement: \n\nRegion: \n\nResponse Condition: \n\nCreated at: 2021-06-15 23:00:00 +0000 UTC\nUpdated at: 2021-06-15 23:00:00 +0000 UTC\nDeleted at: 2021-06-15 23:00:00 +0000 UTC\n\nName: bar\n\nToken: \n\nFormat: \n\nFormat Version: 0\n\nPlacement: \n\nRegion: \n\nResponse Condition: \n\nCreated at: 2021-06-15 23:00:00 +0000 UTC\nUpdated at: 2021-06-15 23:00:00 +0000 UTC\nDeleted at: 2021-06-15 23:00:00 +0000 UTC\n", + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "list"}, scenarios) +} + +func TestNewRelicUpdate(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --name flag", + Args: "--service-id 123 --version 3", + WantError: "error parsing arguments: required flag --name not provided", + }, + { + Name: "validate missing --version flag", + Args: "--name foobar --service-id 123", + WantError: "error parsing arguments: required flag --version not provided", + }, + { + Name: "validate missing --service-id flag", + Args: "--name foobar --version 3", + WantError: "error reading service: no service ID found", + }, + { + Name: "validate missing --autoclone flag with 'active' service", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + }, + Args: "--name foobar --service-id 123 --version 1", + WantError: "service version 1 is active", + }, + { + Name: "validate missing --autoclone flag with 'locked' service", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + }, + Args: "--name foobar --service-id 123 --version 2", + WantError: "service version 2 is locked", + }, + { + Name: "validate UpdateNewRelic API error", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + UpdateNewRelicOTLPFn: func(_ *fastly.UpdateNewRelicOTLPInput) (*fastly.NewRelicOTLP, error) { + return nil, testutil.Err + }, + }, + Args: "--name foobar --new-name beepboop --service-id 123 --version 3", + WantError: testutil.Err.Error(), + }, + { + Name: "validate UpdateNewRelic API success", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + UpdateNewRelicOTLPFn: func(i *fastly.UpdateNewRelicOTLPInput) (*fastly.NewRelicOTLP, error) { + return &fastly.NewRelicOTLP{ + Name: i.NewName, + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + }, nil + }, + }, + Args: "--name foobar --new-name beepboop --service-id 123 --version 3", + WantOutput: "Updated New Relic OTLP logging endpoint 'beepboop' (previously: foobar, service: 123, version: 3)", + }, + { + Name: "validate --autoclone results in cloned service version", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + UpdateNewRelicOTLPFn: func(i *fastly.UpdateNewRelicOTLPInput) (*fastly.NewRelicOTLP, error) { + return &fastly.NewRelicOTLP{ + Name: i.NewName, + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + }, nil + }, + }, + Args: "--autoclone --name foobar --new-name beepboop --service-id 123 --version 1", + WantOutput: "Updated New Relic OTLP logging endpoint 'beepboop' (previously: foobar, service: 123, version: 4)", + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "update"}, scenarios) +} + +func getNewRelic(i *fastly.GetNewRelicOTLPInput) (*fastly.NewRelicOTLP, error) { + t := testutil.Date + + return &fastly.NewRelicOTLP{ + Name: fastly.ToPointer(i.Name), + Token: fastly.ToPointer("abc"), + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + + CreatedAt: &t, + DeletedAt: &t, + UpdatedAt: &t, + }, nil +} + +func listNewRelic(i *fastly.ListNewRelicOTLPInput) ([]*fastly.NewRelicOTLP, error) { + t := testutil.Date + vs := []*fastly.NewRelicOTLP{ + { + Name: fastly.ToPointer("foo"), + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + + CreatedAt: &t, + DeletedAt: &t, + UpdatedAt: &t, + }, + { + Name: fastly.ToPointer("bar"), + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + + CreatedAt: &t, + DeletedAt: &t, + UpdatedAt: &t, + }, + } + return vs, nil +} diff --git a/pkg/commands/logging/newrelicotlp/root.go b/pkg/commands/logging/newrelicotlp/root.go new file mode 100644 index 000000000..72f039380 --- /dev/null +++ b/pkg/commands/logging/newrelicotlp/root.go @@ -0,0 +1,31 @@ +package newrelicotlp + +import ( + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" +) + +// RootCommand is the parent command for all subcommands in this package. +// It should be installed under the primary root command. +type RootCommand struct { + argparser.Base + // no flags +} + +// CommandName is the string to be used to invoke this command. +const CommandName = "newrelicotlp" + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent argparser.Registerer, g *global.Data) *RootCommand { + var c RootCommand + c.Globals = g + c.CmdClause = parent.Command(CommandName, "Manipulate a NewRelic OTLP logging endpoint for a specific Fastly service version") + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { + panic("unreachable") +} diff --git a/pkg/commands/logging/newrelicotlp/update.go b/pkg/commands/logging/newrelicotlp/update.go new file mode 100644 index 000000000..9043c6554 --- /dev/null +++ b/pkg/commands/logging/newrelicotlp/update.go @@ -0,0 +1,165 @@ +package newrelicotlp + +import ( + "fmt" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/logging/common" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// UpdateCommand calls the Fastly API to update an appropriate resource. +type UpdateCommand struct { + argparser.Base + + endpointName string + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion + + autoClone argparser.OptionalAutoClone + format argparser.OptionalString + formatVersion argparser.OptionalInt + key argparser.OptionalString + newName argparser.OptionalString + placement argparser.OptionalString + region argparser.OptionalString + responseCondition argparser.OptionalString + url argparser.OptionalString +} + +// NewUpdateCommand returns a usable command registered under the parent. +func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { + c := UpdateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("update", "Update a New Relic Logs logging object for a particular service and version") + + // Required. + c.CmdClause.Flag("name", "The name for the real-time logging configuration to update").Required().StringVar(&c.endpointName) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.autoClone.Set, + Dst: &c.autoClone.Value, + }) + common.Format(c.CmdClause, &c.format) + c.CmdClause.Flag("format-version", "The version of the custom logging format used for the configured endpoint").Action(c.formatVersion.Set).IntVar(&c.formatVersion.Value) + c.CmdClause.Flag("key", "The Insert API key from the Account page of your New Relic account").Action(c.key.Set).StringVar(&c.key.Value) + c.CmdClause.Flag("new-name", "The name for the real-time logging configuration").Action(c.newName.Set).StringVar(&c.newName.Value) + c.CmdClause.Flag("placement", "Where in the generated VCL the logging call should be placed").Action(c.placement.Set).StringVar(&c.placement.Value) + c.CmdClause.Flag("region", "The region to which to stream logs").Action(c.region.Set).StringVar(&c.region.Value) + c.CmdClause.Flag("response-condition", "The name of an existing condition in the configured endpoint").Action(c.responseCondition.Set).StringVar(&c.responseCondition.Value) + c.CmdClause.Flag("url", "URL of the New Relic Trace Observer, if you are using New Relic Infinite Tracing").Action(c.url.Set).StringVar(&c.url.Value) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + + return &c +} + +// Exec invokes the application logic for the command. +func (c *UpdateCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.autoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + input := c.constructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + + l, err := c.Globals.APIClient.UpdateNewRelicOTLP(input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fastly.ToValue(serviceVersion.Number), + }) + return err + } + + var prev string + if c.newName.WasSet { + prev = fmt.Sprintf("previously: %s, ", c.endpointName) + } + + text.Success(out, + "Updated New Relic OTLP logging endpoint '%s' (%sservice: %s, version: %d)", + fastly.ToValue(l.Name), + prev, + fastly.ToValue(l.ServiceID), + fastly.ToValue(l.ServiceVersion), + ) + return nil +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *UpdateCommand) constructInput(serviceID string, serviceVersion int) *fastly.UpdateNewRelicOTLPInput { + var input fastly.UpdateNewRelicOTLPInput + + input.Name = c.endpointName + input.ServiceID = serviceID + input.ServiceVersion = serviceVersion + + if c.format.WasSet { + input.Format = fastly.ToPointer(argparser.Content(c.format.Value)) + } + if c.formatVersion.WasSet { + input.FormatVersion = &c.formatVersion.Value + } + if c.key.WasSet { + input.Token = &c.key.Value + } + if c.newName.WasSet { + input.NewName = &c.newName.Value + } + if c.placement.WasSet { + input.Placement = &c.placement.Value + } + if c.region.WasSet { + input.Region = &c.region.Value + } + if c.responseCondition.WasSet { + input.ResponseCondition = &c.responseCondition.Value + } + if c.url.WasSet { + input.URL = &c.url.Value + } + + return &input +} diff --git a/pkg/commands/logging/openstack/create.go b/pkg/commands/logging/openstack/create.go new file mode 100644 index 000000000..545084e4c --- /dev/null +++ b/pkg/commands/logging/openstack/create.go @@ -0,0 +1,216 @@ +package openstack + +import ( + "fmt" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/logging/common" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/text" +) + +// CreateCommand calls the Fastly API to create an OpenStack logging endpoint. +type CreateCommand struct { + argparser.Base + Manifest manifest.Data + + // Required. + ServiceName argparser.OptionalServiceNameID + ServiceVersion argparser.OptionalServiceVersion + + // Optional. + AccessKey argparser.OptionalString + AutoClone argparser.OptionalAutoClone + BucketName argparser.OptionalString + CompressionCodec argparser.OptionalString + EndpointName argparser.OptionalString // Can't shadow argparser.Base method Name(). + Format argparser.OptionalString + FormatVersion argparser.OptionalInt + GzipLevel argparser.OptionalInt + MessageType argparser.OptionalString + Path argparser.OptionalString + Period argparser.OptionalInt + Placement argparser.OptionalString + PublicKey argparser.OptionalString + ResponseCondition argparser.OptionalString + TimestampFormat argparser.OptionalString + URL argparser.OptionalString + User argparser.OptionalString +} + +// NewCreateCommand returns a usable command registered under the parent. +func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { + c := CreateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("create", "Create an OpenStack logging endpoint on a Fastly service version").Alias("add") + + // Required. + c.CmdClause.Flag("name", "The name of the OpenStack logging object. Used as a primary key for API access").Short('n').Action(c.EndpointName.Set).StringVar(&c.EndpointName.Value) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.ServiceVersion.Value, + Required: true, + }) + + // Optional. + c.CmdClause.Flag("access-key", "Your OpenStack account access key").Action(c.AccessKey.Set).StringVar(&c.AccessKey.Value) + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.AutoClone.Set, + Dst: &c.AutoClone.Value, + }) + c.CmdClause.Flag("bucket", "The name of your OpenStack container").Action(c.BucketName.Set).StringVar(&c.BucketName.Value) + common.CompressionCodec(c.CmdClause, &c.CompressionCodec) + common.Format(c.CmdClause, &c.Format) + common.FormatVersion(c.CmdClause, &c.FormatVersion) + common.GzipLevel(c.CmdClause, &c.GzipLevel) + common.MessageType(c.CmdClause, &c.MessageType) + common.Path(c.CmdClause, &c.Path) + common.Period(c.CmdClause, &c.Period) + common.Placement(c.CmdClause, &c.Placement) + common.PublicKey(c.CmdClause, &c.PublicKey) + common.ResponseCondition(c.CmdClause, &c.ResponseCondition) + common.TimestampFormat(c.CmdClause, &c.TimestampFormat) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.ServiceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.ServiceName.Value, + }) + c.CmdClause.Flag("url", "Your OpenStack auth url").Action(c.URL.Set).StringVar(&c.URL.Value) + c.CmdClause.Flag("user", "The username for your OpenStack account").Action(c.User.Set).StringVar(&c.User.Value) + return &c +} + +// ConstructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *CreateCommand) ConstructInput(serviceID string, serviceVersion int) (*fastly.CreateOpenstackInput, error) { + var input fastly.CreateOpenstackInput + + input.ServiceID = serviceID + input.ServiceVersion = serviceVersion + if c.EndpointName.WasSet { + input.Name = &c.EndpointName.Value + } + if c.BucketName.WasSet { + input.BucketName = &c.BucketName.Value + } + if c.AccessKey.WasSet { + input.AccessKey = &c.AccessKey.Value + } + if c.User.WasSet { + input.User = &c.User.Value + } + if c.URL.WasSet { + input.URL = &c.URL.Value + } + + // The following blocks enforces the mutual exclusivity of the + // CompressionCodec and GzipLevel flags. + if c.CompressionCodec.WasSet && c.GzipLevel.WasSet { + return nil, fmt.Errorf("error parsing arguments: the --compression-codec flag is mutually exclusive with the --gzip-level flag") + } + + if c.PublicKey.WasSet { + input.PublicKey = &c.PublicKey.Value + } + + if c.Path.WasSet { + input.Path = &c.Path.Value + } + + if c.Period.WasSet { + input.Period = &c.Period.Value + } + + if c.GzipLevel.WasSet { + input.GzipLevel = &c.GzipLevel.Value + } + + if c.Format.WasSet { + input.Format = fastly.ToPointer(argparser.Content(c.Format.Value)) + } + + if c.FormatVersion.WasSet { + input.FormatVersion = &c.FormatVersion.Value + } + + if c.ResponseCondition.WasSet { + input.ResponseCondition = &c.ResponseCondition.Value + } + + if c.MessageType.WasSet { + input.MessageType = &c.MessageType.Value + } + + if c.TimestampFormat.WasSet { + input.TimestampFormat = &c.TimestampFormat.Value + } + + if c.Placement.WasSet { + input.Placement = &c.Placement.Value + } + + if c.CompressionCodec.WasSet { + input.CompressionCodec = &c.CompressionCodec.Value + } + + return &input, nil +} + +// Exec invokes the application logic for the command. +func (c *CreateCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.AutoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.ServiceName, + ServiceVersionFlag: c.ServiceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + input, err := c.ConstructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + d, err := c.Globals.APIClient.CreateOpenstack(input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + text.Success(out, + "Created OpenStack logging endpoint %s (service %s version %d)", + fastly.ToValue(d.Name), + fastly.ToValue(d.ServiceID), + fastly.ToValue(d.ServiceVersion), + ) + return nil +} diff --git a/pkg/commands/logging/openstack/delete.go b/pkg/commands/logging/openstack/delete.go new file mode 100644 index 000000000..63622d3bc --- /dev/null +++ b/pkg/commands/logging/openstack/delete.go @@ -0,0 +1,94 @@ +package openstack + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// DeleteCommand calls the Fastly API to delete an OpenStack logging endpoint. +type DeleteCommand struct { + argparser.Base + Input fastly.DeleteOpenstackInput + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion + autoClone argparser.OptionalAutoClone +} + +// NewDeleteCommand returns a usable command registered under the parent. +func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { + c := DeleteCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("delete", "Delete an OpenStack logging endpoint on a Fastly service version").Alias("remove") + + // Required. + c.CmdClause.Flag("name", "The name of the OpenStack logging object").Short('n').Required().StringVar(&c.Input.Name) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.autoClone.Set, + Dst: &c.autoClone.Value, + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + return &c +} + +// Exec invokes the application logic for the command. +func (c *DeleteCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.autoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + c.Input.ServiceID = serviceID + c.Input.ServiceVersion = fastly.ToValue(serviceVersion.Number) + + if err := c.Globals.APIClient.DeleteOpenstack(&c.Input); err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + text.Success(out, "Deleted OpenStack logging endpoint %s (service %s version %d)", c.Input.Name, c.Input.ServiceID, c.Input.ServiceVersion) + return nil +} diff --git a/pkg/commands/logging/openstack/describe.go b/pkg/commands/logging/openstack/describe.go new file mode 100644 index 000000000..15bf8587f --- /dev/null +++ b/pkg/commands/logging/openstack/describe.go @@ -0,0 +1,119 @@ +package openstack + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// DescribeCommand calls the Fastly API to describe an OpenStack logging endpoint. +type DescribeCommand struct { + argparser.Base + argparser.JSONOutput + + Input fastly.GetOpenstackInput + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion +} + +// NewDescribeCommand returns a usable command registered under the parent. +func NewDescribeCommand(parent argparser.Registerer, g *global.Data) *DescribeCommand { + c := DescribeCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("describe", "Show detailed information about an OpenStack logging endpoint on a Fastly service version").Alias("get") + + // Required. + c.CmdClause.Flag("name", "The name of the OpenStack logging object").Short('n').Required().StringVar(&c.Input.Name) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + return &c +} + +// Exec invokes the application logic for the command. +func (c *DescribeCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fsterr.ServiceVersion(serviceVersion), + }) + return err + } + + c.Input.ServiceID = serviceID + c.Input.ServiceVersion = fastly.ToValue(serviceVersion.Number) + + o, err := c.Globals.APIClient.GetOpenstack(&c.Input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + lines := text.Lines{ + "Access key": fastly.ToValue(o.AccessKey), + "Bucket": fastly.ToValue(o.BucketName), + "Compression codec": fastly.ToValue(o.CompressionCodec), + "Format version": fastly.ToValue(o.FormatVersion), + "Format": fastly.ToValue(o.Format), + "GZip level": fastly.ToValue(o.GzipLevel), + "Message type": fastly.ToValue(o.MessageType), + "Name": fastly.ToValue(o.Name), + "Path": fastly.ToValue(o.Path), + "Period": fastly.ToValue(o.Period), + "Placement": fastly.ToValue(o.Placement), + "Public key": fastly.ToValue(o.PublicKey), + "Response condition": fastly.ToValue(o.ResponseCondition), + "Timestamp format": fastly.ToValue(o.TimestampFormat), + "URL": fastly.ToValue(o.URL), + "User": fastly.ToValue(o.User), + "Version": fastly.ToValue(o.ServiceVersion), + } + if !c.Globals.Verbose() { + lines["Service ID"] = fastly.ToValue(o.ServiceID) + } + text.PrintLines(out, lines) + + return nil +} diff --git a/pkg/logging/openstack/doc.go b/pkg/commands/logging/openstack/doc.go similarity index 100% rename from pkg/logging/openstack/doc.go rename to pkg/commands/logging/openstack/doc.go diff --git a/pkg/commands/logging/openstack/list.go b/pkg/commands/logging/openstack/list.go new file mode 100644 index 000000000..73f593aec --- /dev/null +++ b/pkg/commands/logging/openstack/list.go @@ -0,0 +1,133 @@ +package openstack + +import ( + "fmt" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// ListCommand calls the Fastly API to list OpenStack logging endpoints. +type ListCommand struct { + argparser.Base + argparser.JSONOutput + + Input fastly.ListOpenstackInput + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion +} + +// NewListCommand returns a usable command registered under the parent. +func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { + c := ListCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("list", "List OpenStack logging endpoints on a Fastly service version") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + return &c +} + +// Exec invokes the application logic for the command. +func (c *ListCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fsterr.ServiceVersion(serviceVersion), + }) + return err + } + + c.Input.ServiceID = serviceID + c.Input.ServiceVersion = fastly.ToValue(serviceVersion.Number) + + o, err := c.Globals.APIClient.ListOpenstack(&c.Input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + if !c.Globals.Verbose() { + tw := text.NewTable(out) + tw.AddHeader("SERVICE", "VERSION", "NAME") + for _, openstack := range o { + tw.AddLine( + fastly.ToValue(openstack.ServiceID), + fastly.ToValue(openstack.ServiceVersion), + fastly.ToValue(openstack.Name), + ) + } + tw.Print() + return nil + } + + fmt.Fprintf(out, "Version: %d\n", c.Input.ServiceVersion) + for i, openstack := range o { + fmt.Fprintf(out, "\tOpenstack %d/%d\n", i+1, len(o)) + fmt.Fprintf(out, "\t\tService ID: %s\n", fastly.ToValue(openstack.ServiceID)) + fmt.Fprintf(out, "\t\tVersion: %d\n", fastly.ToValue(openstack.ServiceVersion)) + fmt.Fprintf(out, "\t\tName: %s\n", fastly.ToValue(openstack.Name)) + fmt.Fprintf(out, "\t\tBucket: %s\n", fastly.ToValue(openstack.BucketName)) + fmt.Fprintf(out, "\t\tAccess key: %s\n", fastly.ToValue(openstack.AccessKey)) + fmt.Fprintf(out, "\t\tUser: %s\n", fastly.ToValue(openstack.User)) + fmt.Fprintf(out, "\t\tURL: %s\n", fastly.ToValue(openstack.URL)) + fmt.Fprintf(out, "\t\tPath: %s\n", fastly.ToValue(openstack.Path)) + fmt.Fprintf(out, "\t\tPeriod: %d\n", fastly.ToValue(openstack.Period)) + fmt.Fprintf(out, "\t\tGZip level: %d\n", fastly.ToValue(openstack.GzipLevel)) + fmt.Fprintf(out, "\t\tFormat: %s\n", fastly.ToValue(openstack.Format)) + fmt.Fprintf(out, "\t\tFormat version: %d\n", fastly.ToValue(openstack.FormatVersion)) + fmt.Fprintf(out, "\t\tResponse condition: %s\n", fastly.ToValue(openstack.ResponseCondition)) + fmt.Fprintf(out, "\t\tMessage type: %s\n", fastly.ToValue(openstack.MessageType)) + fmt.Fprintf(out, "\t\tTimestamp format: %s\n", fastly.ToValue(openstack.TimestampFormat)) + fmt.Fprintf(out, "\t\tPlacement: %s\n", fastly.ToValue(openstack.Placement)) + fmt.Fprintf(out, "\t\tPublic key: %s\n", fastly.ToValue(openstack.PublicKey)) + fmt.Fprintf(out, "\t\tCompression codec: %s\n", fastly.ToValue(openstack.CompressionCodec)) + } + fmt.Fprintln(out) + + return nil +} diff --git a/pkg/commands/logging/openstack/openstack_integration_test.go b/pkg/commands/logging/openstack/openstack_integration_test.go new file mode 100644 index 000000000..700e0185e --- /dev/null +++ b/pkg/commands/logging/openstack/openstack_integration_test.go @@ -0,0 +1,516 @@ +package openstack_test + +import ( + "bytes" + "errors" + "io" + "strings" + "testing" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/app" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" +) + +func TestOpenstackCreate(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging openstack create --service-id 123 --version 1 --name log --bucket log --access-key foo --user user --url https://example.com --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + CreateOpenstackFn: createOpenstackOK, + }, + wantOutput: "Created OpenStack logging endpoint log (service 123 version 4)", + }, + { + args: args("logging openstack create --service-id 123 --version 1 --name log --bucket log --access-key foo --user user --url https://example.com --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + CreateOpenstackFn: createOpenstackError, + }, + wantError: errTest.Error(), + }, + { + args: args("logging openstack create --service-id 123 --version 1 --name log --bucket log --access-key foo --user user --url https://example.com --compression-codec zstd --gzip-level 9 --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + }, + wantError: "error parsing arguments: the --compression-codec flag is mutually exclusive with the --gzip-level flag", + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertStringContains(t, stdout.String(), testcase.wantOutput) + }) + } +} + +func TestOpenstackList(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging openstack list --service-id 123 --version 1"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListOpenstacksFn: listOpenstacksOK, + }, + wantOutput: listOpenstacksShortOutput, + }, + { + args: args("logging openstack list --service-id 123 --version 1 --verbose"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListOpenstacksFn: listOpenstacksOK, + }, + wantOutput: listOpenstacksVerboseOutput, + }, + { + args: args("logging openstack list --service-id 123 --version 1 -v"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListOpenstacksFn: listOpenstacksOK, + }, + wantOutput: listOpenstacksVerboseOutput, + }, + { + args: args("logging openstack --verbose list --service-id 123 --version 1"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListOpenstacksFn: listOpenstacksOK, + }, + wantOutput: listOpenstacksVerboseOutput, + }, + { + args: args("logging -v openstack list --service-id 123 --version 1"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListOpenstacksFn: listOpenstacksOK, + }, + wantOutput: listOpenstacksVerboseOutput, + }, + { + args: args("logging openstack list --service-id 123 --version 1"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListOpenstacksFn: listOpenstacksError, + }, + wantError: errTest.Error(), + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertString(t, testcase.wantOutput, stdout.String()) + }) + } +} + +func TestOpenstackDescribe(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging openstack describe --service-id 123 --version 1"), + wantError: "error parsing arguments: required flag --name not provided", + }, + { + args: args("logging openstack describe --service-id 123 --version 1 --name logs"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + GetOpenstackFn: getOpenstackError, + }, + wantError: errTest.Error(), + }, + { + args: args("logging openstack describe --service-id 123 --version 1 --name logs"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + GetOpenstackFn: getOpenstackOK, + }, + wantOutput: describeOpenstackOutput, + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertString(t, testcase.wantOutput, stdout.String()) + }) + } +} + +func TestOpenstackUpdate(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging openstack update --service-id 123 --version 1 --new-name log"), + wantError: "error parsing arguments: required flag --name not provided", + }, + { + args: args("logging openstack update --service-id 123 --version 1 --name logs --new-name log --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + UpdateOpenstackFn: updateOpenstackError, + }, + wantError: errTest.Error(), + }, + { + args: args("logging openstack update --service-id 123 --version 1 --name logs --new-name log --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + UpdateOpenstackFn: updateOpenstackOK, + }, + wantOutput: "Updated OpenStack logging endpoint log (service 123 version 4)", + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertStringContains(t, stdout.String(), testcase.wantOutput) + }) + } +} + +func TestOpenstackDelete(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging openstack delete --service-id 123 --version 1"), + wantError: "error parsing arguments: required flag --name not provided", + }, + { + args: args("logging openstack delete --service-id 123 --version 1 --name logs --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + DeleteOpenstackFn: deleteOpenstackError, + }, + wantError: errTest.Error(), + }, + { + args: args("logging openstack delete --service-id 123 --version 1 --name logs --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + DeleteOpenstackFn: deleteOpenstackOK, + }, + wantOutput: "Deleted OpenStack logging endpoint logs (service 123 version 4)", + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertStringContains(t, stdout.String(), testcase.wantOutput) + }) + } +} + +var errTest = errors.New("fixture error") + +func createOpenstackOK(i *fastly.CreateOpenstackInput) (*fastly.Openstack, error) { + s := fastly.Openstack{ + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + } + + if i.Name != nil { + s.Name = i.Name + } + + return &s, nil +} + +func createOpenstackError(_ *fastly.CreateOpenstackInput) (*fastly.Openstack, error) { + return nil, errTest +} + +func listOpenstacksOK(i *fastly.ListOpenstackInput) ([]*fastly.Openstack, error) { + return []*fastly.Openstack{ + { + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("logs"), + BucketName: fastly.ToPointer("my-logs"), + AccessKey: fastly.ToPointer("1234"), + User: fastly.ToPointer("user"), + URL: fastly.ToPointer("https://example.com"), + Path: fastly.ToPointer("logs/"), + Period: fastly.ToPointer(3600), + GzipLevel: fastly.ToPointer(0), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + FormatVersion: fastly.ToPointer(2), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + MessageType: fastly.ToPointer("classic"), + TimestampFormat: fastly.ToPointer("%Y-%m-%dT%H:%M:%S.000"), + Placement: fastly.ToPointer("none"), + PublicKey: fastly.ToPointer(pgpPublicKey()), + CompressionCodec: fastly.ToPointer("zstd"), + }, + { + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("analytics"), + BucketName: fastly.ToPointer("analytics"), + AccessKey: fastly.ToPointer("1234"), + User: fastly.ToPointer("user2"), + URL: fastly.ToPointer("https://two.example.com"), + Path: fastly.ToPointer("logs/"), + Period: fastly.ToPointer(86400), + GzipLevel: fastly.ToPointer(0), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + FormatVersion: fastly.ToPointer(2), + MessageType: fastly.ToPointer("classic"), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + TimestampFormat: fastly.ToPointer("%Y-%m-%dT%H:%M:%S.000"), + Placement: fastly.ToPointer("none"), + PublicKey: fastly.ToPointer(pgpPublicKey()), + CompressionCodec: fastly.ToPointer("zstd"), + }, + }, nil +} + +func listOpenstacksError(_ *fastly.ListOpenstackInput) ([]*fastly.Openstack, error) { + return nil, errTest +} + +var listOpenstacksShortOutput = strings.TrimSpace(` +SERVICE VERSION NAME +123 1 logs +123 1 analytics +`) + "\n" + +var listOpenstacksVerboseOutput = strings.TrimSpace(` +Fastly API endpoint: https://api.fastly.com +Fastly API token provided via config file (profile: user) + +Service ID (via --service-id): 123 + +Version: 1 + Openstack 1/2 + Service ID: 123 + Version: 1 + Name: logs + Bucket: my-logs + Access key: 1234 + User: user + URL: https://example.com + Path: logs/ + Period: 3600 + GZip level: 0 + Format: %h %l %u %t "%r" %>s %b + Format version: 2 + Response condition: Prevent default logging + Message type: classic + Timestamp format: %Y-%m-%dT%H:%M:%S.000 + Placement: none + Public key: `+pgpPublicKey()+` + Compression codec: zstd + Openstack 2/2 + Service ID: 123 + Version: 1 + Name: analytics + Bucket: analytics + Access key: 1234 + User: user2 + URL: https://two.example.com + Path: logs/ + Period: 86400 + GZip level: 0 + Format: %h %l %u %t "%r" %>s %b + Format version: 2 + Response condition: Prevent default logging + Message type: classic + Timestamp format: %Y-%m-%dT%H:%M:%S.000 + Placement: none + Public key: `+pgpPublicKey()+` + Compression codec: zstd +`) + "\n\n" + +func getOpenstackOK(i *fastly.GetOpenstackInput) (*fastly.Openstack, error) { + return &fastly.Openstack{ + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("logs"), + BucketName: fastly.ToPointer("my-logs"), + AccessKey: fastly.ToPointer("1234"), + User: fastly.ToPointer("user"), + URL: fastly.ToPointer("https://example.com"), + Path: fastly.ToPointer("logs/"), + Period: fastly.ToPointer(3600), + GzipLevel: fastly.ToPointer(0), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + FormatVersion: fastly.ToPointer(2), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + MessageType: fastly.ToPointer("classic"), + TimestampFormat: fastly.ToPointer("%Y-%m-%dT%H:%M:%S.000"), + Placement: fastly.ToPointer("none"), + PublicKey: fastly.ToPointer(pgpPublicKey()), + CompressionCodec: fastly.ToPointer("zstd"), + }, nil +} + +func getOpenstackError(_ *fastly.GetOpenstackInput) (*fastly.Openstack, error) { + return nil, errTest +} + +var describeOpenstackOutput = "\n" + strings.TrimSpace(` +Access key: 1234 +Bucket: my-logs +Compression codec: zstd +Format: %h %l %u %t "%r" %>s %b +Format version: 2 +GZip level: 0 +Message type: classic +Name: logs +Path: logs/ +Period: 3600 +Placement: none +Public key: `+pgpPublicKey()+` +Response condition: Prevent default logging +Service ID: 123 +Timestamp format: %Y-%m-%dT%H:%M:%S.000 +URL: https://example.com +User: user +Version: 1 +`) + "\n" + +func updateOpenstackOK(i *fastly.UpdateOpenstackInput) (*fastly.Openstack, error) { + return &fastly.Openstack{ + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("log"), + BucketName: fastly.ToPointer("my-logs"), + AccessKey: fastly.ToPointer("1234"), + User: fastly.ToPointer("userupdate"), + URL: fastly.ToPointer("https://update.example.com"), + Path: fastly.ToPointer("logs/"), + Period: fastly.ToPointer(3600), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + FormatVersion: fastly.ToPointer(2), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + MessageType: fastly.ToPointer("classic"), + TimestampFormat: fastly.ToPointer("%Y-%m-%dT%H:%M:%S.000"), + Placement: fastly.ToPointer("none"), + PublicKey: fastly.ToPointer(pgpPublicKey()), + CompressionCodec: fastly.ToPointer("zstd"), + }, nil +} + +func updateOpenstackError(_ *fastly.UpdateOpenstackInput) (*fastly.Openstack, error) { + return nil, errTest +} + +func deleteOpenstackOK(_ *fastly.DeleteOpenstackInput) error { + return nil +} + +func deleteOpenstackError(_ *fastly.DeleteOpenstackInput) error { + return errTest +} + +// pgpPublicKey returns a PEM encoded PGP public key suitable for testing. +func pgpPublicKey() string { + return strings.TrimSpace(`-----BEGIN PGP PUBLIC KEY BLOCK----- +mQENBFyUD8sBCACyFnB39AuuTygseek+eA4fo0cgwva6/FSjnWq7riouQee8GgQ/ +ibXTRyv4iVlwI12GswvMTIy7zNvs1R54i0qvsLr+IZ4GVGJqs6ZJnvQcqe3xPoR4 +8AnBfw90o32r/LuHf6QCJXi+AEu35koNlNAvLJ2B+KACaNB7N0EeWmqpV/1V2k9p +lDYk+th7LcCuaFNGqKS/PrMnnMqR6VDLCjHhNx4KR79b0Twm/2qp6an3hyNRu8Gn +dwxpf1/BUu3JWf+LqkN4Y3mbOmSUL3MaJNvyQguUzTfS0P0uGuBDHrJCVkMZCzDB +89ag55jCPHyGeHBTd02gHMWzsg3WMBWvCsrzABEBAAG0JXRlcnJhZm9ybSAodGVz +dCkgPHRlc3RAdGVycmFmb3JtLmNvbT6JAU4EEwEIADgWIQSHYyc6Kj9l6HzQsau6 +vFFc9jxV/wUCXJQPywIbAwULCQgHAgYVCgkICwIEFgIDAQIeAQIXgAAKCRC6vFFc +9jxV/815CAClb32OxV7wG01yF97TzlyTl8TnvjMtoG29Mw4nSyg+mjM3b8N7iXm9 +OLX59fbDAWtBSldSZE22RXd3CvlFOG/EnKBXSjBtEqfyxYSnyOPkMPBYWGL/ApkX +SvPYJ4LKdvipYToKFh3y9kk2gk1DcDBDyaaHvR+3rv1u3aoy7/s2EltAfDS3ZQIq +7/cWTLJml/lleeB/Y6rPj8xqeCYhE5ahw9gsV/Mdqatl24V9Tks30iijx0Hhw+Gx +kATUikMGr2GDVqoIRga5kXI7CzYff4rkc0Twn47fMHHHe/KY9M2yVnMHUXmAZwbG +M1cMI/NH1DjevCKdGBLcRJlhuLPKF/anuQENBFyUD8sBCADIpd7r7GuPd6n/Ikxe +u6h7umV6IIPoAm88xCYpTbSZiaK30Svh6Ywra9jfE2KlU9o6Y/art8ip0VJ3m07L +4RSfSpnzqgSwdjSq5hNour2Fo/BzYhK7yaz2AzVSbe33R0+RYhb4b/6N+bKbjwGF +ftCsqVFMH+PyvYkLbvxyQrHlA9woAZaNThI1ztO5rGSnGUR8xt84eup28WIFKg0K +UEGUcTzz+8QGAwAra+0ewPXo/AkO+8BvZjDidP417u6gpBHOJ9qYIcO9FxHeqFyu +YrjlrxowEgXn5wO8xuNz6Vu1vhHGDHGDsRbZF8pv1d5O+0F1G7ttZ2GRRgVBZPwi +kiyRABEBAAGJATYEGAEIACAWIQSHYyc6Kj9l6HzQsau6vFFc9jxV/wUCXJQPywIb +DAAKCRC6vFFc9jxV/9YOCACe8qmOSnKQpQfW+PqYOqo3dt7JyweTs3FkD6NT8Zml +dYy/vkstbTjPpX6aTvUZjkb46BVi7AOneVHpD5GBqvRsZ9iVgDYHaehmLCdKiG5L +3Tp90NN+QY5WDbsGmsyk6+6ZMYejb4qYfweQeduOj27aavCJdLkCYMoRKfcFYI8c +FaNmEfKKy/r1PO20NXEG6t9t05K/frHy6ZG8bCNYdpagfFVot47r9JaQqWlTNtIR +5+zkkSq/eG9BEtRij3a6cTdQbktdBzx2KBeI0PYc1vlZR0LpuFKZqY9vlE6vTGLR +wMfrTEOvx0NxUM3rpaCgEmuWbB1G1Hu371oyr4srrr+N +=28dr +-----END PGP PUBLIC KEY BLOCK----- +`) +} diff --git a/pkg/commands/logging/openstack/openstack_test.go b/pkg/commands/logging/openstack/openstack_test.go new file mode 100644 index 000000000..517f8eca6 --- /dev/null +++ b/pkg/commands/logging/openstack/openstack_test.go @@ -0,0 +1,380 @@ +package openstack_test + +import ( + "bytes" + "testing" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/logging/openstack" + "github.com/fastly/cli/pkg/config" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" +) + +func TestCreateOpenstackInput(t *testing.T) { + for _, testcase := range []struct { + name string + cmd *openstack.CreateCommand + want *fastly.CreateOpenstackInput + wantError string + }{ + { + name: "required values set flag serviceID", + cmd: createCommandRequired(), + want: &fastly.CreateOpenstackInput{ + ServiceID: "123", + ServiceVersion: 4, + Name: fastly.ToPointer("log"), + BucketName: fastly.ToPointer("bucket"), + AccessKey: fastly.ToPointer("access"), + User: fastly.ToPointer("user"), + URL: fastly.ToPointer("https://example.com"), + }, + }, + { + name: "all values set flag serviceID", + cmd: createCommandAll(), + want: &fastly.CreateOpenstackInput{ + ServiceID: "123", + ServiceVersion: 4, + Name: fastly.ToPointer("log"), + BucketName: fastly.ToPointer("bucket"), + AccessKey: fastly.ToPointer("access"), + User: fastly.ToPointer("user"), + URL: fastly.ToPointer("https://example.com"), + Path: fastly.ToPointer("/log"), + Period: fastly.ToPointer(3600), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + MessageType: fastly.ToPointer("classic"), + FormatVersion: fastly.ToPointer(2), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + TimestampFormat: fastly.ToPointer("%Y-%m-%dT%H:%M:%S.000"), + Placement: fastly.ToPointer("none"), + PublicKey: fastly.ToPointer(pgpPublicKey()), + CompressionCodec: fastly.ToPointer("zstd"), + }, + }, + { + name: "error missing serviceID", + cmd: createCommandMissingServiceID(), + want: nil, + wantError: errors.ErrNoServiceID.Error(), + }, + } { + t.Run(testcase.name, func(t *testing.T) { + var bs []byte + out := bytes.NewBuffer(bs) + verboseMode := true + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + AutoCloneFlag: testcase.cmd.AutoClone, + APIClient: testcase.cmd.Globals.APIClient, + Manifest: testcase.cmd.Manifest, + Out: out, + ServiceVersionFlag: testcase.cmd.ServiceVersion, + VerboseMode: verboseMode, + }) + + switch { + case err != nil && testcase.wantError == "": + t.Fatalf("unexpected error getting service details: %v", err) + return + case err != nil && testcase.wantError != "": + testutil.AssertErrorContains(t, err, testcase.wantError) + return + case err == nil && testcase.wantError != "": + t.Fatalf("expected error, have nil (service details: %s, %d)", serviceID, serviceVersion.Number) + case err == nil && testcase.wantError == "": + have, err := testcase.cmd.ConstructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertEqual(t, testcase.want, have) + } + }) + } +} + +func TestUpdateOpenstackInput(t *testing.T) { + scenarios := []struct { + name string + cmd *openstack.UpdateCommand + api mock.API + want *fastly.UpdateOpenstackInput + wantError string + }{ + { + name: "all values set flag serviceID", + cmd: updateCommandAll(), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + GetOpenstackFn: getOpenstackOK, + }, + want: &fastly.UpdateOpenstackInput{ + ServiceID: "123", + ServiceVersion: 4, + Name: "log", + NewName: fastly.ToPointer("new1"), + BucketName: fastly.ToPointer("new2"), + User: fastly.ToPointer("new3"), + AccessKey: fastly.ToPointer("new4"), + URL: fastly.ToPointer("new5"), + Path: fastly.ToPointer("new6"), + Period: fastly.ToPointer(3601), + GzipLevel: fastly.ToPointer(0), + Format: fastly.ToPointer("new7"), + FormatVersion: fastly.ToPointer(3), + ResponseCondition: fastly.ToPointer("new8"), + MessageType: fastly.ToPointer("new9"), + TimestampFormat: fastly.ToPointer("new10"), + Placement: fastly.ToPointer("new11"), + PublicKey: fastly.ToPointer("new12"), + CompressionCodec: fastly.ToPointer("new13"), + }, + }, + { + name: "no updates", + cmd: updateCommandNoUpdates(), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + GetOpenstackFn: getOpenstackOK, + }, + want: &fastly.UpdateOpenstackInput{ + ServiceID: "123", + ServiceVersion: 4, + Name: "log", + }, + }, + { + name: "error missing serviceID", + cmd: updateCommandMissingServiceID(), + want: nil, + wantError: errors.ErrNoServiceID.Error(), + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(testcase.name, func(t *testing.T) { + testcase.cmd.Globals.APIClient = testcase.api + + var bs []byte + out := bytes.NewBuffer(bs) + verboseMode := true + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + AutoCloneFlag: testcase.cmd.AutoClone, + APIClient: testcase.api, + Manifest: testcase.cmd.Manifest, + Out: out, + ServiceVersionFlag: testcase.cmd.ServiceVersion, + VerboseMode: verboseMode, + }) + + switch { + case err != nil && testcase.wantError == "": + t.Fatalf("unexpected error getting service details: %v", err) + return + case err != nil && testcase.wantError != "": + testutil.AssertErrorContains(t, err, testcase.wantError) + return + case err == nil && testcase.wantError != "": + t.Fatalf("expected error, have nil (service details: %s, %d)", serviceID, serviceVersion.Number) + case err == nil && testcase.wantError == "": + have, err := testcase.cmd.ConstructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertEqual(t, testcase.want, have) + } + }) + } +} + +func createCommandRequired() *openstack.CreateCommand { + var b bytes.Buffer + + g := global.Data{ + Config: config.File{}, + Env: config.Environment{}, + Output: &b, + } + g.APIClient, _ = mock.APIClient(mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + })("token", "endpoint", false) + + return &openstack.CreateCommand{ + Base: argparser.Base{ + Globals: &g, + }, + Manifest: manifest.Data{ + Flag: manifest.Flag{ + ServiceID: "123", + }, + }, + ServiceVersion: argparser.OptionalServiceVersion{ + OptionalString: argparser.OptionalString{Value: "1"}, + }, + AutoClone: argparser.OptionalAutoClone{ + OptionalBool: argparser.OptionalBool{ + Optional: argparser.Optional{ + WasSet: true, + }, + Value: true, + }, + }, + EndpointName: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "log"}, + BucketName: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "bucket"}, + AccessKey: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "access"}, + User: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "user"}, + URL: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "https://example.com"}, + } +} + +func createCommandAll() *openstack.CreateCommand { + var b bytes.Buffer + + g := global.Data{ + Config: config.File{}, + Env: config.Environment{}, + Output: &b, + } + g.APIClient, _ = mock.APIClient(mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + })("token", "endpoint", false) + + return &openstack.CreateCommand{ + Base: argparser.Base{ + Globals: &g, + }, + Manifest: manifest.Data{ + Flag: manifest.Flag{ + ServiceID: "123", + }, + }, + ServiceVersion: argparser.OptionalServiceVersion{ + OptionalString: argparser.OptionalString{Value: "1"}, + }, + AutoClone: argparser.OptionalAutoClone{ + OptionalBool: argparser.OptionalBool{ + Optional: argparser.Optional{ + WasSet: true, + }, + Value: true, + }, + }, + EndpointName: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "log"}, + BucketName: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "bucket"}, + AccessKey: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "access"}, + User: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "user"}, + URL: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "https://example.com"}, + Path: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "/log"}, + Period: argparser.OptionalInt{Optional: argparser.Optional{WasSet: true}, Value: 3600}, + Format: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: `%h %l %u %t "%r" %>s %b`}, + FormatVersion: argparser.OptionalInt{Optional: argparser.Optional{WasSet: true}, Value: 2}, + ResponseCondition: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "Prevent default logging"}, + TimestampFormat: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "%Y-%m-%dT%H:%M:%S.000"}, + Placement: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "none"}, + MessageType: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "classic"}, + PublicKey: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: pgpPublicKey()}, + CompressionCodec: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "zstd"}, + } +} + +func createCommandMissingServiceID() *openstack.CreateCommand { + res := createCommandAll() + res.Manifest = manifest.Data{} + return res +} + +func updateCommandNoUpdates() *openstack.UpdateCommand { + var b bytes.Buffer + + g := global.Data{ + Config: config.File{}, + Env: config.Environment{}, + Output: &b, + } + + return &openstack.UpdateCommand{ + Base: argparser.Base{ + Globals: &g, + }, + Manifest: manifest.Data{ + Flag: manifest.Flag{ + ServiceID: "123", + }, + }, + EndpointName: "log", + ServiceVersion: argparser.OptionalServiceVersion{ + OptionalString: argparser.OptionalString{Value: "1"}, + }, + AutoClone: argparser.OptionalAutoClone{ + OptionalBool: argparser.OptionalBool{ + Optional: argparser.Optional{ + WasSet: true, + }, + Value: true, + }, + }, + } +} + +func updateCommandAll() *openstack.UpdateCommand { + var b bytes.Buffer + + g := global.Data{ + Config: config.File{}, + Env: config.Environment{}, + Output: &b, + } + + return &openstack.UpdateCommand{ + Base: argparser.Base{ + Globals: &g, + }, + Manifest: manifest.Data{ + Flag: manifest.Flag{ + ServiceID: "123", + }, + }, + EndpointName: "log", + ServiceVersion: argparser.OptionalServiceVersion{ + OptionalString: argparser.OptionalString{Value: "1"}, + }, + AutoClone: argparser.OptionalAutoClone{ + OptionalBool: argparser.OptionalBool{ + Optional: argparser.Optional{ + WasSet: true, + }, + Value: true, + }, + }, + NewName: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new1"}, + BucketName: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new2"}, + User: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new3"}, + AccessKey: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new4"}, + URL: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new5"}, + Path: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new6"}, + Period: argparser.OptionalInt{Optional: argparser.Optional{WasSet: true}, Value: 3601}, + GzipLevel: argparser.OptionalInt{Optional: argparser.Optional{WasSet: true}, Value: 0}, + Format: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new7"}, + FormatVersion: argparser.OptionalInt{Optional: argparser.Optional{WasSet: true}, Value: 3}, + ResponseCondition: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new8"}, + MessageType: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new9"}, + TimestampFormat: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new10"}, + Placement: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new11"}, + PublicKey: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new12"}, + CompressionCodec: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new13"}, + } +} + +func updateCommandMissingServiceID() *openstack.UpdateCommand { + res := updateCommandAll() + res.Manifest = manifest.Data{} + return res +} diff --git a/pkg/commands/logging/openstack/root.go b/pkg/commands/logging/openstack/root.go new file mode 100644 index 000000000..d6d8b3bb8 --- /dev/null +++ b/pkg/commands/logging/openstack/root.go @@ -0,0 +1,31 @@ +package openstack + +import ( + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" +) + +// RootCommand is the parent command for all subcommands in this package. +// It should be installed under the primary root command. +type RootCommand struct { + argparser.Base + // no flags +} + +// CommandName is the string to be used to invoke this command. +const CommandName = "openstack" + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent argparser.Registerer, g *global.Data) *RootCommand { + var c RootCommand + c.Globals = g + c.CmdClause = parent.Command(CommandName, "Manipulate Fastly service version OpenStack logging endpoints") + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { + panic("unreachable") +} diff --git a/pkg/commands/logging/openstack/update.go b/pkg/commands/logging/openstack/update.go new file mode 100644 index 000000000..ee985a15f --- /dev/null +++ b/pkg/commands/logging/openstack/update.go @@ -0,0 +1,219 @@ +package openstack + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/logging/common" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/text" +) + +// UpdateCommand calls the Fastly API to update an OpenStack logging endpoint. +type UpdateCommand struct { + argparser.Base + Manifest manifest.Data + + // Required. + EndpointName string + ServiceName argparser.OptionalServiceNameID + ServiceVersion argparser.OptionalServiceVersion + + // Optional. + AutoClone argparser.OptionalAutoClone + NewName argparser.OptionalString + BucketName argparser.OptionalString + AccessKey argparser.OptionalString + User argparser.OptionalString + URL argparser.OptionalString + Path argparser.OptionalString + Period argparser.OptionalInt + GzipLevel argparser.OptionalInt + Format argparser.OptionalString + FormatVersion argparser.OptionalInt + ResponseCondition argparser.OptionalString + MessageType argparser.OptionalString + TimestampFormat argparser.OptionalString + Placement argparser.OptionalString + PublicKey argparser.OptionalString + CompressionCodec argparser.OptionalString +} + +// NewUpdateCommand returns a usable command registered under the parent. +func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { + c := UpdateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("update", "Update an OpenStack logging endpoint on a Fastly service version") + + // Required. + c.CmdClause.Flag("name", "The name of the OpenStack logging object").Short('n').Required().StringVar(&c.EndpointName) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.ServiceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.AutoClone.Set, + Dst: &c.AutoClone.Value, + }) + c.CmdClause.Flag("access-key", "Your OpenStack account access key").Action(c.AccessKey.Set).StringVar(&c.AccessKey.Value) + c.CmdClause.Flag("bucket", "The name of the Openstack Space").Action(c.BucketName.Set).StringVar(&c.BucketName.Value) + common.CompressionCodec(c.CmdClause, &c.CompressionCodec) + common.Format(c.CmdClause, &c.Format) + common.FormatVersion(c.CmdClause, &c.FormatVersion) + common.GzipLevel(c.CmdClause, &c.GzipLevel) + common.MessageType(c.CmdClause, &c.MessageType) + c.CmdClause.Flag("new-name", "New name of the OpenStack logging object").Action(c.NewName.Set).StringVar(&c.NewName.Value) + common.Path(c.CmdClause, &c.Path) + common.Period(c.CmdClause, &c.Period) + common.Placement(c.CmdClause, &c.Placement) + common.PublicKey(c.CmdClause, &c.PublicKey) + common.ResponseCondition(c.CmdClause, &c.ResponseCondition) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.ServiceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.ServiceName.Value, + }) + common.TimestampFormat(c.CmdClause, &c.TimestampFormat) + c.CmdClause.Flag("url", "Your OpenStack auth url.").Action(c.URL.Set).StringVar(&c.URL.Value) + c.CmdClause.Flag("user", "The username for your OpenStack account.").Action(c.User.Set).StringVar(&c.User.Value) + + return &c +} + +// ConstructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *UpdateCommand) ConstructInput(serviceID string, serviceVersion int) (*fastly.UpdateOpenstackInput, error) { + input := fastly.UpdateOpenstackInput{ + ServiceID: serviceID, + ServiceVersion: serviceVersion, + Name: c.EndpointName, + } + + // Set new values if set by user. + if c.NewName.WasSet { + input.NewName = &c.NewName.Value + } + + if c.BucketName.WasSet { + input.BucketName = &c.BucketName.Value + } + + if c.AccessKey.WasSet { + input.AccessKey = &c.AccessKey.Value + } + + if c.User.WasSet { + input.User = &c.User.Value + } + + if c.URL.WasSet { + input.URL = &c.URL.Value + } + + if c.Path.WasSet { + input.Path = &c.Path.Value + } + + if c.Period.WasSet { + input.Period = &c.Period.Value + } + + if c.GzipLevel.WasSet { + input.GzipLevel = &c.GzipLevel.Value + } + + if c.Format.WasSet { + input.Format = fastly.ToPointer(argparser.Content(c.Format.Value)) + } + + if c.FormatVersion.WasSet { + input.FormatVersion = &c.FormatVersion.Value + } + + if c.ResponseCondition.WasSet { + input.ResponseCondition = &c.ResponseCondition.Value + } + + if c.MessageType.WasSet { + input.MessageType = &c.MessageType.Value + } + + if c.TimestampFormat.WasSet { + input.TimestampFormat = &c.TimestampFormat.Value + } + + if c.Placement.WasSet { + input.Placement = &c.Placement.Value + } + + if c.PublicKey.WasSet { + input.PublicKey = &c.PublicKey.Value + } + + if c.CompressionCodec.WasSet { + input.CompressionCodec = &c.CompressionCodec.Value + } + + return &input, nil +} + +// Exec invokes the application logic for the command. +func (c *UpdateCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.AutoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.ServiceName, + ServiceVersionFlag: c.ServiceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + input, err := c.ConstructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + openstack, err := c.Globals.APIClient.UpdateOpenstack(input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + text.Success(out, + "Updated OpenStack logging endpoint %s (service %s version %d)", + fastly.ToValue(openstack.Name), + fastly.ToValue(openstack.ServiceID), + fastly.ToValue(openstack.ServiceVersion), + ) + return nil +} diff --git a/pkg/commands/logging/papertrail/create.go b/pkg/commands/logging/papertrail/create.go new file mode 100644 index 000000000..a5abc98e3 --- /dev/null +++ b/pkg/commands/logging/papertrail/create.go @@ -0,0 +1,158 @@ +package papertrail + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/logging/common" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/text" +) + +// CreateCommand calls the Fastly API to create a Papertrail logging endpoint. +type CreateCommand struct { + argparser.Base + Manifest manifest.Data + + // Required. + ServiceName argparser.OptionalServiceNameID + ServiceVersion argparser.OptionalServiceVersion + + // Optional. + Address argparser.OptionalString + AutoClone argparser.OptionalAutoClone + EndpointName argparser.OptionalString // Can't shadow argparser.Base method Name(). + Format argparser.OptionalString + FormatVersion argparser.OptionalInt + Placement argparser.OptionalString + Port argparser.OptionalInt + ResponseCondition argparser.OptionalString +} + +// NewCreateCommand returns a usable command registered under the parent. +func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { + c := CreateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("create", "Create a Papertrail logging endpoint on a Fastly service version").Alias("add") + + // Required. + c.CmdClause.Flag("name", "The name of the Papertrail logging object. Used as a primary key for API access").Short('n').Action(c.EndpointName.Set).StringVar(&c.EndpointName.Value) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.ServiceVersion.Value, + Required: true, + }) + + // Optional. + c.CmdClause.Flag("address", "A hostname or IPv4 address").Action(c.Address.Set).StringVar(&c.Address.Value) + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.AutoClone.Set, + Dst: &c.AutoClone.Value, + }) + common.FormatVersion(c.CmdClause, &c.FormatVersion) + common.Format(c.CmdClause, &c.Format) + common.Placement(c.CmdClause, &c.Placement) + c.CmdClause.Flag("port", "The port number").Action(c.Port.Set).IntVar(&c.Port.Value) + common.ResponseCondition(c.CmdClause, &c.ResponseCondition) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.ServiceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.ServiceName.Value, + }) + return &c +} + +// ConstructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *CreateCommand) ConstructInput(serviceID string, serviceVersion int) (*fastly.CreatePapertrailInput, error) { + var input fastly.CreatePapertrailInput + + input.ServiceID = serviceID + if c.EndpointName.WasSet { + input.Name = &c.EndpointName.Value + } + input.ServiceVersion = serviceVersion + if c.Address.WasSet { + input.Address = &c.Address.Value + } + + if c.Port.WasSet { + input.Port = &c.Port.Value + } + + if c.Format.WasSet { + input.Format = fastly.ToPointer(argparser.Content(c.Format.Value)) + } + + if c.FormatVersion.WasSet { + input.FormatVersion = &c.FormatVersion.Value + } + + if c.ResponseCondition.WasSet { + input.ResponseCondition = &c.ResponseCondition.Value + } + + if c.Placement.WasSet { + input.Placement = &c.Placement.Value + } + + return &input, nil +} + +// Exec invokes the application logic for the command. +func (c *CreateCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.AutoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.ServiceName, + ServiceVersionFlag: c.ServiceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + input, err := c.ConstructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + d, err := c.Globals.APIClient.CreatePapertrail(input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + text.Success(out, + "Created Papertrail logging endpoint %s (service %s version %d)", + fastly.ToValue(d.Name), + fastly.ToValue(d.ServiceID), + fastly.ToValue(d.ServiceVersion), + ) + return nil +} diff --git a/pkg/commands/logging/papertrail/delete.go b/pkg/commands/logging/papertrail/delete.go new file mode 100644 index 000000000..ef082fb4d --- /dev/null +++ b/pkg/commands/logging/papertrail/delete.go @@ -0,0 +1,94 @@ +package papertrail + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// DeleteCommand calls the Fastly API to delete a Papertrail logging endpoint. +type DeleteCommand struct { + argparser.Base + Input fastly.DeletePapertrailInput + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion + autoClone argparser.OptionalAutoClone +} + +// NewDeleteCommand returns a usable command registered under the parent. +func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { + c := DeleteCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("delete", "Delete a Papertrail logging endpoint on a Fastly service version").Alias("remove") + + // Required. + c.CmdClause.Flag("name", "The name of the Papertrail logging object").Short('n').Required().StringVar(&c.Input.Name) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.autoClone.Set, + Dst: &c.autoClone.Value, + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + return &c +} + +// Exec invokes the application logic for the command. +func (c *DeleteCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.autoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + c.Input.ServiceID = serviceID + c.Input.ServiceVersion = fastly.ToValue(serviceVersion.Number) + + if err := c.Globals.APIClient.DeletePapertrail(&c.Input); err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + text.Success(out, "Deleted Papertrail logging endpoint %s (service %s version %d)", c.Input.Name, c.Input.ServiceID, c.Input.ServiceVersion) + return nil +} diff --git a/pkg/commands/logging/papertrail/describe.go b/pkg/commands/logging/papertrail/describe.go new file mode 100644 index 000000000..cf73218f1 --- /dev/null +++ b/pkg/commands/logging/papertrail/describe.go @@ -0,0 +1,110 @@ +package papertrail + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// DescribeCommand calls the Fastly API to describe a Papertrail logging endpoint. +type DescribeCommand struct { + argparser.Base + argparser.JSONOutput + + Input fastly.GetPapertrailInput + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion +} + +// NewDescribeCommand returns a usable command registered under the parent. +func NewDescribeCommand(parent argparser.Registerer, g *global.Data) *DescribeCommand { + c := DescribeCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("describe", "Show detailed information about a Papertrail logging endpoint on a Fastly service version").Alias("get") + + // Required. + c.CmdClause.Flag("name", "The name of the Papertrail logging object").Short('n').Required().StringVar(&c.Input.Name) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + return &c +} + +// Exec invokes the application logic for the command. +func (c *DescribeCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fsterr.ServiceVersion(serviceVersion), + }) + return err + } + + c.Input.ServiceID = serviceID + c.Input.ServiceVersion = fastly.ToValue(serviceVersion.Number) + + o, err := c.Globals.APIClient.GetPapertrail(&c.Input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + lines := text.Lines{ + "Address": fastly.ToValue(o.Address), + "Format version": fastly.ToValue(o.FormatVersion), + "Format": fastly.ToValue(o.Format), + "Name": fastly.ToValue(o.Name), + "Placement": fastly.ToValue(o.Placement), + "Port": fastly.ToValue(o.Port), + "Response condition": fastly.ToValue(o.ResponseCondition), + "Version": fastly.ToValue(o.ServiceVersion), + } + if !c.Globals.Verbose() { + lines["Service ID"] = fastly.ToValue(o.ServiceID) + } + text.PrintLines(out, lines) + + return nil +} diff --git a/pkg/logging/papertrail/doc.go b/pkg/commands/logging/papertrail/doc.go similarity index 100% rename from pkg/logging/papertrail/doc.go rename to pkg/commands/logging/papertrail/doc.go diff --git a/pkg/commands/logging/papertrail/list.go b/pkg/commands/logging/papertrail/list.go new file mode 100644 index 000000000..4239dbe3f --- /dev/null +++ b/pkg/commands/logging/papertrail/list.go @@ -0,0 +1,124 @@ +package papertrail + +import ( + "fmt" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// ListCommand calls the Fastly API to list Papertrail logging endpoints. +type ListCommand struct { + argparser.Base + argparser.JSONOutput + + Input fastly.ListPapertrailsInput + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion +} + +// NewListCommand returns a usable command registered under the parent. +func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { + c := ListCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("list", "List Papertrail endpoints on a Fastly service version") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + return &c +} + +// Exec invokes the application logic for the command. +func (c *ListCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fsterr.ServiceVersion(serviceVersion), + }) + return err + } + + c.Input.ServiceID = serviceID + c.Input.ServiceVersion = fastly.ToValue(serviceVersion.Number) + + o, err := c.Globals.APIClient.ListPapertrails(&c.Input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + if !c.Globals.Verbose() { + tw := text.NewTable(out) + tw.AddHeader("SERVICE", "VERSION", "NAME") + for _, papertrail := range o { + tw.AddLine( + fastly.ToValue(papertrail.ServiceID), + fastly.ToValue(papertrail.ServiceVersion), + fastly.ToValue(papertrail.Name), + ) + } + tw.Print() + return nil + } + + fmt.Fprintf(out, "Version: %d\n", c.Input.ServiceVersion) + for i, papertrail := range o { + fmt.Fprintf(out, "\tPapertrail %d/%d\n", i+1, len(o)) + fmt.Fprintf(out, "\t\tService ID: %s\n", fastly.ToValue(papertrail.ServiceID)) + fmt.Fprintf(out, "\t\tVersion: %d\n", fastly.ToValue(papertrail.ServiceVersion)) + fmt.Fprintf(out, "\t\tName: %s\n", fastly.ToValue(papertrail.Name)) + fmt.Fprintf(out, "\t\tAddress: %s\n", fastly.ToValue(papertrail.Address)) + fmt.Fprintf(out, "\t\tPort: %d\n", fastly.ToValue(papertrail.Port)) + fmt.Fprintf(out, "\t\tFormat: %s\n", fastly.ToValue(papertrail.Format)) + fmt.Fprintf(out, "\t\tFormat version: %d\n", fastly.ToValue(papertrail.FormatVersion)) + fmt.Fprintf(out, "\t\tResponse condition: %s\n", fastly.ToValue(papertrail.ResponseCondition)) + fmt.Fprintf(out, "\t\tPlacement: %s\n", fastly.ToValue(papertrail.Placement)) + } + fmt.Fprintln(out) + + return nil +} diff --git a/pkg/commands/logging/papertrail/papertrail_integration_test.go b/pkg/commands/logging/papertrail/papertrail_integration_test.go new file mode 100644 index 000000000..acfa5ccaf --- /dev/null +++ b/pkg/commands/logging/papertrail/papertrail_integration_test.go @@ -0,0 +1,407 @@ +package papertrail_test + +import ( + "bytes" + "errors" + "io" + "strings" + "testing" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/app" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" +) + +func TestPapertrailCreate(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging papertrail create --service-id 123 --version 1 --name log --address example.com:123 --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + CreatePapertrailFn: createPapertrailOK, + }, + wantOutput: "Created Papertrail logging endpoint log (service 123 version 4)", + }, + { + args: args("logging papertrail create --service-id 123 --version 1 --name log --address example.com:123 --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + CreatePapertrailFn: createPapertrailError, + }, + wantError: errTest.Error(), + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertStringContains(t, stdout.String(), testcase.wantOutput) + }) + } +} + +func TestPapertrailList(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging papertrail list --service-id 123 --version 1"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListPapertrailsFn: listPapertrailsOK, + }, + wantOutput: listPapertrailsShortOutput, + }, + { + args: args("logging papertrail list --service-id 123 --version 1 --verbose"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListPapertrailsFn: listPapertrailsOK, + }, + wantOutput: listPapertrailsVerboseOutput, + }, + { + args: args("logging papertrail list --service-id 123 --version 1 -v"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListPapertrailsFn: listPapertrailsOK, + }, + wantOutput: listPapertrailsVerboseOutput, + }, + { + args: args("logging papertrail --verbose list --service-id 123 --version 1"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListPapertrailsFn: listPapertrailsOK, + }, + wantOutput: listPapertrailsVerboseOutput, + }, + { + args: args("logging -v papertrail list --service-id 123 --version 1"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListPapertrailsFn: listPapertrailsOK, + }, + wantOutput: listPapertrailsVerboseOutput, + }, + { + args: args("logging papertrail list --service-id 123 --version 1"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListPapertrailsFn: listPapertrailsError, + }, + wantError: errTest.Error(), + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertString(t, testcase.wantOutput, stdout.String()) + }) + } +} + +func TestPapertrailDescribe(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging papertrail describe --service-id 123 --version 1"), + wantError: "error parsing arguments: required flag --name not provided", + }, + { + args: args("logging papertrail describe --service-id 123 --version 1 --name logs"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + GetPapertrailFn: getPapertrailError, + }, + wantError: errTest.Error(), + }, + { + args: args("logging papertrail describe --service-id 123 --version 1 --name logs"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + GetPapertrailFn: getPapertrailOK, + }, + wantOutput: describePapertrailOutput, + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertString(t, testcase.wantOutput, stdout.String()) + }) + } +} + +func TestPapertrailUpdate(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging papertrail update --service-id 123 --version 1 --new-name log"), + wantError: "error parsing arguments: required flag --name not provided", + }, + { + args: args("logging papertrail update --service-id 123 --version 1 --name logs --new-name log --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + UpdatePapertrailFn: updatePapertrailError, + }, + wantError: errTest.Error(), + }, + { + args: args("logging papertrail update --service-id 123 --version 1 --name logs --new-name log --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + UpdatePapertrailFn: updatePapertrailOK, + }, + wantOutput: "Updated Papertrail logging endpoint log (service 123 version 4)", + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertStringContains(t, stdout.String(), testcase.wantOutput) + }) + } +} + +func TestPapertrailDelete(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging papertrail delete --service-id 123 --version 1"), + wantError: "error parsing arguments: required flag --name not provided", + }, + { + args: args("logging papertrail delete --service-id 123 --version 1 --name logs --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + DeletePapertrailFn: deletePapertrailError, + }, + wantError: errTest.Error(), + }, + { + args: args("logging papertrail delete --service-id 123 --version 1 --name logs --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + DeletePapertrailFn: deletePapertrailOK, + }, + wantOutput: "Deleted Papertrail logging endpoint logs (service 123 version 4)", + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertStringContains(t, stdout.String(), testcase.wantOutput) + }) + } +} + +var errTest = errors.New("fixture error") + +func createPapertrailOK(i *fastly.CreatePapertrailInput) (*fastly.Papertrail, error) { + return &fastly.Papertrail{ + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: i.Name, + }, nil +} + +func createPapertrailError(_ *fastly.CreatePapertrailInput) (*fastly.Papertrail, error) { + return nil, errTest +} + +func listPapertrailsOK(i *fastly.ListPapertrailsInput) ([]*fastly.Papertrail, error) { + return []*fastly.Papertrail{ + { + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("logs"), + Address: fastly.ToPointer("example.com:123"), + Port: fastly.ToPointer(123), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + FormatVersion: fastly.ToPointer(2), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + Placement: fastly.ToPointer("none"), + }, + { + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("analytics"), + Address: fastly.ToPointer("127.0.0.1:456"), + Port: fastly.ToPointer(456), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + FormatVersion: fastly.ToPointer(2), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + Placement: fastly.ToPointer("none"), + }, + }, nil +} + +func listPapertrailsError(_ *fastly.ListPapertrailsInput) ([]*fastly.Papertrail, error) { + return nil, errTest +} + +var listPapertrailsShortOutput = strings.TrimSpace(` +SERVICE VERSION NAME +123 1 logs +123 1 analytics +`) + "\n" + +var listPapertrailsVerboseOutput = strings.TrimSpace(` +Fastly API endpoint: https://api.fastly.com +Fastly API token provided via config file (profile: user) + +Service ID (via --service-id): 123 + +Version: 1 + Papertrail 1/2 + Service ID: 123 + Version: 1 + Name: logs + Address: example.com:123 + Port: 123 + Format: %h %l %u %t "%r" %>s %b + Format version: 2 + Response condition: Prevent default logging + Placement: none + Papertrail 2/2 + Service ID: 123 + Version: 1 + Name: analytics + Address: 127.0.0.1:456 + Port: 456 + Format: %h %l %u %t "%r" %>s %b + Format version: 2 + Response condition: Prevent default logging + Placement: none +`) + "\n\n" + +func getPapertrailOK(i *fastly.GetPapertrailInput) (*fastly.Papertrail, error) { + return &fastly.Papertrail{ + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("logs"), + Address: fastly.ToPointer("example.com:123"), + Port: fastly.ToPointer(123), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + FormatVersion: fastly.ToPointer(2), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + Placement: fastly.ToPointer("none"), + }, nil +} + +func getPapertrailError(_ *fastly.GetPapertrailInput) (*fastly.Papertrail, error) { + return nil, errTest +} + +var describePapertrailOutput = "\n" + strings.TrimSpace(` +Address: example.com:123 +Format: %h %l %u %t "%r" %>s %b +Format version: 2 +Name: logs +Placement: none +Port: 123 +Response condition: Prevent default logging +Service ID: 123 +Version: 1 +`) + "\n" + +func updatePapertrailOK(i *fastly.UpdatePapertrailInput) (*fastly.Papertrail, error) { + return &fastly.Papertrail{ + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("log"), + Address: fastly.ToPointer("example.com:123"), + Port: fastly.ToPointer(123), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + FormatVersion: fastly.ToPointer(2), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + Placement: fastly.ToPointer("none"), + }, nil +} + +func updatePapertrailError(_ *fastly.UpdatePapertrailInput) (*fastly.Papertrail, error) { + return nil, errTest +} + +func deletePapertrailOK(_ *fastly.DeletePapertrailInput) error { + return nil +} + +func deletePapertrailError(_ *fastly.DeletePapertrailInput) error { + return errTest +} diff --git a/pkg/commands/logging/papertrail/papertrail_test.go b/pkg/commands/logging/papertrail/papertrail_test.go new file mode 100644 index 000000000..9c0195a1e --- /dev/null +++ b/pkg/commands/logging/papertrail/papertrail_test.go @@ -0,0 +1,340 @@ +package papertrail_test + +import ( + "bytes" + "testing" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/logging/papertrail" + "github.com/fastly/cli/pkg/config" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" +) + +func TestCreatePapertrailInput(t *testing.T) { + for _, testcase := range []struct { + name string + cmd *papertrail.CreateCommand + want *fastly.CreatePapertrailInput + wantError string + }{ + { + name: "required values set flag serviceID", + cmd: createCommandRequired(), + want: &fastly.CreatePapertrailInput{ + ServiceID: "123", + ServiceVersion: 4, + Name: fastly.ToPointer("log"), + Address: fastly.ToPointer("example.com"), + }, + }, + { + name: "all values set flag serviceID", + cmd: createCommandAll(), + want: &fastly.CreatePapertrailInput{ + ServiceID: "123", + ServiceVersion: 4, + Name: fastly.ToPointer("log"), + Address: fastly.ToPointer("example.com"), + Port: fastly.ToPointer(22), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + FormatVersion: fastly.ToPointer(2), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + Placement: fastly.ToPointer("none"), + }, + }, + { + name: "error missing serviceID", + cmd: createCommandMissingServiceID(), + want: nil, + wantError: errors.ErrNoServiceID.Error(), + }, + } { + t.Run(testcase.name, func(t *testing.T) { + var bs []byte + out := bytes.NewBuffer(bs) + verboseMode := true + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + AutoCloneFlag: testcase.cmd.AutoClone, + APIClient: testcase.cmd.Globals.APIClient, + Manifest: testcase.cmd.Manifest, + Out: out, + ServiceVersionFlag: testcase.cmd.ServiceVersion, + VerboseMode: verboseMode, + }) + + switch { + case err != nil && testcase.wantError == "": + t.Fatalf("unexpected error getting service details: %v", err) + return + case err != nil && testcase.wantError != "": + testutil.AssertErrorContains(t, err, testcase.wantError) + return + case err == nil && testcase.wantError != "": + t.Fatalf("expected error, have nil (service details: %s, %d)", serviceID, serviceVersion.Number) + case err == nil && testcase.wantError == "": + have, err := testcase.cmd.ConstructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertEqual(t, testcase.want, have) + } + }) + } +} + +func TestUpdatePapertrailInput(t *testing.T) { + scenarios := []struct { + name string + cmd *papertrail.UpdateCommand + api mock.API + want *fastly.UpdatePapertrailInput + wantError string + }{ + { + name: "no updates", + cmd: updateCommandNoUpdates(), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + GetPapertrailFn: getPapertrailOK, + }, + want: &fastly.UpdatePapertrailInput{ + ServiceID: "123", + ServiceVersion: 4, + Name: "log", + }, + }, + { + name: "all values set flag serviceID", + cmd: updateCommandAll(), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + GetPapertrailFn: getPapertrailOK, + }, + want: &fastly.UpdatePapertrailInput{ + ServiceID: "123", + ServiceVersion: 4, + Name: "log", + NewName: fastly.ToPointer("new1"), + Address: fastly.ToPointer("new2"), + Port: fastly.ToPointer(23), + Format: fastly.ToPointer("new3"), + FormatVersion: fastly.ToPointer(3), + ResponseCondition: fastly.ToPointer("new4"), + Placement: fastly.ToPointer("new5"), + }, + }, + { + name: "error missing serviceID", + cmd: updateCommandMissingServiceID(), + want: nil, + wantError: errors.ErrNoServiceID.Error(), + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(testcase.name, func(t *testing.T) { + testcase.cmd.Globals.APIClient = testcase.api + + var bs []byte + out := bytes.NewBuffer(bs) + verboseMode := true + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + AutoCloneFlag: testcase.cmd.AutoClone, + APIClient: testcase.api, + Manifest: testcase.cmd.Manifest, + Out: out, + ServiceVersionFlag: testcase.cmd.ServiceVersion, + VerboseMode: verboseMode, + }) + + switch { + case err != nil && testcase.wantError == "": + t.Fatalf("unexpected error getting service details: %v", err) + return + case err != nil && testcase.wantError != "": + testutil.AssertErrorContains(t, err, testcase.wantError) + return + case err == nil && testcase.wantError != "": + t.Fatalf("expected error, have nil (service details: %s, %d)", serviceID, serviceVersion.Number) + case err == nil && testcase.wantError == "": + have, err := testcase.cmd.ConstructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertEqual(t, testcase.want, have) + } + }) + } +} + +func createCommandRequired() *papertrail.CreateCommand { + var b bytes.Buffer + + g := global.Data{ + Config: config.File{}, + Env: config.Environment{}, + Output: &b, + } + g.APIClient, _ = mock.APIClient(mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + })("token", "endpoint", false) + + return &papertrail.CreateCommand{ + Base: argparser.Base{ + Globals: &g, + }, + Manifest: manifest.Data{ + Flag: manifest.Flag{ + ServiceID: "123", + }, + }, + EndpointName: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "log"}, + Address: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "example.com"}, + ServiceVersion: argparser.OptionalServiceVersion{ + OptionalString: argparser.OptionalString{Value: "1"}, + }, + AutoClone: argparser.OptionalAutoClone{ + OptionalBool: argparser.OptionalBool{ + Optional: argparser.Optional{ + WasSet: true, + }, + Value: true, + }, + }, + } +} + +func createCommandAll() *papertrail.CreateCommand { + var b bytes.Buffer + + g := global.Data{ + Config: config.File{}, + Env: config.Environment{}, + Output: &b, + } + g.APIClient, _ = mock.APIClient(mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + })("token", "endpoint", false) + + return &papertrail.CreateCommand{ + Base: argparser.Base{ + Globals: &g, + }, + Manifest: manifest.Data{ + Flag: manifest.Flag{ + ServiceID: "123", + }, + }, + ServiceVersion: argparser.OptionalServiceVersion{ + OptionalString: argparser.OptionalString{Value: "1"}, + }, + AutoClone: argparser.OptionalAutoClone{ + OptionalBool: argparser.OptionalBool{ + Optional: argparser.Optional{ + WasSet: true, + }, + Value: true, + }, + }, + EndpointName: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "log"}, + Address: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "example.com"}, + Format: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: `%h %l %u %t "%r" %>s %b`}, + FormatVersion: argparser.OptionalInt{Optional: argparser.Optional{WasSet: true}, Value: 2}, + ResponseCondition: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "Prevent default logging"}, + Placement: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "none"}, + Port: argparser.OptionalInt{Optional: argparser.Optional{WasSet: true}, Value: 22}, + } +} + +func createCommandMissingServiceID() *papertrail.CreateCommand { + res := createCommandAll() + res.Manifest = manifest.Data{} + return res +} + +func updateCommandNoUpdates() *papertrail.UpdateCommand { + var b bytes.Buffer + + g := global.Data{ + Config: config.File{}, + Env: config.Environment{}, + Output: &b, + } + + return &papertrail.UpdateCommand{ + Base: argparser.Base{ + Globals: &g, + }, + Manifest: manifest.Data{ + Flag: manifest.Flag{ + ServiceID: "123", + }, + }, + EndpointName: "log", + ServiceVersion: argparser.OptionalServiceVersion{ + OptionalString: argparser.OptionalString{Value: "1"}, + }, + AutoClone: argparser.OptionalAutoClone{ + OptionalBool: argparser.OptionalBool{ + Optional: argparser.Optional{ + WasSet: true, + }, + Value: true, + }, + }, + } +} + +func updateCommandAll() *papertrail.UpdateCommand { + var b bytes.Buffer + + g := global.Data{ + Config: config.File{}, + Env: config.Environment{}, + Output: &b, + } + + return &papertrail.UpdateCommand{ + Base: argparser.Base{ + Globals: &g, + }, + Manifest: manifest.Data{ + Flag: manifest.Flag{ + ServiceID: "123", + }, + }, + EndpointName: "log", + ServiceVersion: argparser.OptionalServiceVersion{ + OptionalString: argparser.OptionalString{Value: "1"}, + }, + AutoClone: argparser.OptionalAutoClone{ + OptionalBool: argparser.OptionalBool{ + Optional: argparser.Optional{ + WasSet: true, + }, + Value: true, + }, + }, + NewName: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new1"}, + Address: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new2"}, + Port: argparser.OptionalInt{Optional: argparser.Optional{WasSet: true}, Value: 23}, + Format: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new3"}, + FormatVersion: argparser.OptionalInt{Optional: argparser.Optional{WasSet: true}, Value: 3}, + ResponseCondition: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new4"}, + Placement: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new5"}, + } +} + +func updateCommandMissingServiceID() *papertrail.UpdateCommand { + res := updateCommandAll() + res.Manifest = manifest.Data{} + return res +} diff --git a/pkg/commands/logging/papertrail/root.go b/pkg/commands/logging/papertrail/root.go new file mode 100644 index 000000000..1a81dc358 --- /dev/null +++ b/pkg/commands/logging/papertrail/root.go @@ -0,0 +1,31 @@ +package papertrail + +import ( + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" +) + +// RootCommand is the parent command for all subcommands in this package. +// It should be installed under the primary root command. +type RootCommand struct { + argparser.Base + // no flags +} + +// CommandName is the string to be used to invoke this command. +const CommandName = "papertrail" + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent argparser.Registerer, g *global.Data) *RootCommand { + var c RootCommand + c.Globals = g + c.CmdClause = parent.Command(CommandName, "Manipulate Fastly service version Papertrail logging endpoints.") + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { + panic("unreachable") +} diff --git a/pkg/commands/logging/papertrail/update.go b/pkg/commands/logging/papertrail/update.go new file mode 100644 index 000000000..fae6937fe --- /dev/null +++ b/pkg/commands/logging/papertrail/update.go @@ -0,0 +1,168 @@ +package papertrail + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/logging/common" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/text" +) + +// UpdateCommand calls the Fastly API to update a Papertrail logging endpoint. +type UpdateCommand struct { + argparser.Base + Manifest manifest.Data + + // Required. + EndpointName string + ServiceName argparser.OptionalServiceNameID + ServiceVersion argparser.OptionalServiceVersion + + // Optional. + AutoClone argparser.OptionalAutoClone + NewName argparser.OptionalString + Address argparser.OptionalString + Port argparser.OptionalInt + FormatVersion argparser.OptionalInt + Format argparser.OptionalString + ResponseCondition argparser.OptionalString + Placement argparser.OptionalString +} + +// NewUpdateCommand returns a usable command registered under the parent. +func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { + c := UpdateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("update", "Update a Papertrail logging endpoint on a Fastly service version") + + // Required. + c.CmdClause.Flag("name", "The name of the Papertrail logging object").Short('n').Required().StringVar(&c.EndpointName) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.ServiceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.AutoClone.Set, + Dst: &c.AutoClone.Value, + }) + c.CmdClause.Flag("address", "A hostname or IPv4 address").Action(c.Address.Set).StringVar(&c.Address.Value) + common.Format(c.CmdClause, &c.Format) + common.FormatVersion(c.CmdClause, &c.FormatVersion) + c.CmdClause.Flag("new-name", "New name of the Papertrail logging object").Action(c.NewName.Set).StringVar(&c.NewName.Value) + common.Placement(c.CmdClause, &c.Placement) + c.CmdClause.Flag("port", "The port number").Action(c.Port.Set).IntVar(&c.Port.Value) + common.ResponseCondition(c.CmdClause, &c.ResponseCondition) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.ServiceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.ServiceName.Value, + }) + return &c +} + +// ConstructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *UpdateCommand) ConstructInput(serviceID string, serviceVersion int) (*fastly.UpdatePapertrailInput, error) { + input := fastly.UpdatePapertrailInput{ + ServiceID: serviceID, + ServiceVersion: serviceVersion, + Name: c.EndpointName, + } + + // Set new values if set by user. + if c.NewName.WasSet { + input.NewName = &c.NewName.Value + } + + if c.NewName.WasSet { + input.NewName = &c.NewName.Value + } + + if c.Address.WasSet { + input.Address = &c.Address.Value + } + + if c.Port.WasSet { + input.Port = &c.Port.Value + } + + if c.FormatVersion.WasSet { + input.FormatVersion = &c.FormatVersion.Value + } + + if c.Format.WasSet { + input.Format = fastly.ToPointer(argparser.Content(c.Format.Value)) + } + + if c.ResponseCondition.WasSet { + input.ResponseCondition = &c.ResponseCondition.Value + } + + if c.Placement.WasSet { + input.Placement = &c.Placement.Value + } + + return &input, nil +} + +// Exec invokes the application logic for the command. +func (c *UpdateCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.AutoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.ServiceName, + ServiceVersionFlag: c.ServiceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + input, err := c.ConstructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + papertrail, err := c.Globals.APIClient.UpdatePapertrail(input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + text.Success(out, + "Updated Papertrail logging endpoint %s (service %s version %d)", + fastly.ToValue(papertrail.Name), + fastly.ToValue(papertrail.ServiceID), + fastly.ToValue(papertrail.ServiceVersion), + ) + return nil +} diff --git a/pkg/commands/logging/root.go b/pkg/commands/logging/root.go new file mode 100644 index 000000000..4ec0cf9d1 --- /dev/null +++ b/pkg/commands/logging/root.go @@ -0,0 +1,31 @@ +package logging + +import ( + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" +) + +// RootCommand is the parent command for all subcommands in this package. +// It should be installed under the primary root command. +type RootCommand struct { + argparser.Base + // no flags +} + +// CommandName is the string to be used to invoke this command. +const CommandName = "logging" + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent argparser.Registerer, g *global.Data) *RootCommand { + var c RootCommand + c.Globals = g + c.CmdClause = parent.Command(CommandName, "Manipulate Fastly service version logging endpoints") + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { + panic("unreachable") +} diff --git a/pkg/commands/logging/s3/create.go b/pkg/commands/logging/s3/create.go new file mode 100644 index 000000000..5dec6698e --- /dev/null +++ b/pkg/commands/logging/s3/create.go @@ -0,0 +1,305 @@ +package s3 + +import ( + "fmt" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/logging/common" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/text" +) + +// CreateCommand calls the Fastly API to create an Amazon S3 logging endpoint. +type CreateCommand struct { + argparser.Base + Manifest manifest.Data + + // Required. + ServiceName argparser.OptionalServiceNameID + ServiceVersion argparser.OptionalServiceVersion + + // mutual exclusions + // AccessKey + SecretKey or IAMRole must be provided + AccessKey argparser.OptionalString + SecretKey argparser.OptionalString + IAMRole argparser.OptionalString + + // Optional. + AutoClone argparser.OptionalAutoClone + BucketName argparser.OptionalString + CompressionCodec argparser.OptionalString + Domain argparser.OptionalString + EndpointName argparser.OptionalString // Can't shadow argparser.Base method Name(). + FileMaxBytes argparser.OptionalInt + Format argparser.OptionalString + FormatVersion argparser.OptionalInt + GzipLevel argparser.OptionalInt + MessageType argparser.OptionalString + Path argparser.OptionalString + Period argparser.OptionalInt + Placement argparser.OptionalString + PublicKey argparser.OptionalString + Redundancy argparser.OptionalString + ResponseCondition argparser.OptionalString + ServerSideEncryption argparser.OptionalString + ServerSideEncryptionKMSKeyID argparser.OptionalString + TimestampFormat argparser.OptionalString +} + +// NewCreateCommand returns a usable command registered under the parent. +func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { + c := CreateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("create", "Create an Amazon S3 logging endpoint on a Fastly service version").Alias("add") + + // Required. + c.CmdClause.Flag("name", "The name of the S3 logging object. Used as a primary key for API access").Short('n').Action(c.EndpointName.Set).StringVar(&c.EndpointName.Value) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.ServiceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.AutoClone.Set, + Dst: &c.AutoClone.Value, + }) + c.CmdClause.Flag("access-key", "Your S3 account access key").Action(c.AccessKey.Set).StringVar(&c.AccessKey.Value) + c.CmdClause.Flag("bucket", "Your S3 bucket name").Action(c.BucketName.Set).StringVar(&c.BucketName.Value) + common.CompressionCodec(c.CmdClause, &c.CompressionCodec) + c.CmdClause.Flag("domain", "The domain of the S3 endpoint").Action(c.Domain.Set).StringVar(&c.Domain.Value) + c.CmdClause.Flag("file-max-bytes", "The maximum size of a log file in bytes").Action(c.FileMaxBytes.Set).IntVar(&c.FileMaxBytes.Value) + common.Format(c.CmdClause, &c.Format) + common.FormatVersion(c.CmdClause, &c.FormatVersion) + common.GzipLevel(c.CmdClause, &c.GzipLevel) + c.CmdClause.Flag("iam-role", "The IAM role ARN for logging").Action(c.IAMRole.Set).StringVar(&c.IAMRole.Value) + common.MessageType(c.CmdClause, &c.MessageType) + common.Path(c.CmdClause, &c.Path) + common.Period(c.CmdClause, &c.Period) + common.Placement(c.CmdClause, &c.Placement) + common.PublicKey(c.CmdClause, &c.PublicKey) + c.CmdClause.Flag("redundancy", "The S3 storage class. One of: standard, intelligent_tiering, standard_ia, onezone_ia, glacier, glacier_ir, deep_archive, or reduced_redundancy").Action(c.Redundancy.Set).EnumVar(&c.Redundancy.Value, string(fastly.S3RedundancyStandard), string(fastly.S3RedundancyIntelligentTiering), string(fastly.S3RedundancyStandardIA), string(fastly.S3RedundancyOneZoneIA), string(fastly.S3RedundancyGlacierFlexibleRetrieval), string(fastly.S3RedundancyGlacierInstantRetrieval), string(fastly.S3RedundancyGlacierDeepArchive), string(fastly.S3RedundancyReduced)) + common.ResponseCondition(c.CmdClause, &c.ResponseCondition) + c.CmdClause.Flag("secret-key", "Your S3 account secret key").Action(c.SecretKey.Set).StringVar(&c.SecretKey.Value) + c.CmdClause.Flag("server-side-encryption", "Set to enable S3 Server Side Encryption. Can be either AES256 or aws:kms").Action(c.ServerSideEncryption.Set).EnumVar(&c.ServerSideEncryption.Value, string(fastly.S3ServerSideEncryptionAES), string(fastly.S3ServerSideEncryptionKMS)) + c.CmdClause.Flag("server-side-encryption-kms-key-id", "Server-side KMS Key ID. Must be set if server-side-encryption is set to aws:kms").Action(c.ServerSideEncryptionKMSKeyID.Set).StringVar(&c.ServerSideEncryptionKMSKeyID.Value) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.ServiceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.ServiceName.Value, + }) + common.TimestampFormat(c.CmdClause, &c.TimestampFormat) + return &c +} + +// ConstructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *CreateCommand) ConstructInput(serviceID string, serviceVersion int) (*fastly.CreateS3Input, error) { + var input fastly.CreateS3Input + + input.ServiceID = serviceID + input.ServiceVersion = serviceVersion + if c.EndpointName.WasSet { + input.Name = &c.EndpointName.Value + } + if c.BucketName.WasSet { + input.BucketName = &c.BucketName.Value + } + + // The following block checks for invalid permutations of the ways in + // which the AccessKey + SecretKey and IAMRole flags can be + // provided. This is necessary because either the AccessKey and + // SecretKey or the IAMRole is required, but they are mutually + // exclusive. The kingpin library lacks a way to express this constraint + // via the flag specification API so we enforce it manually here. + switch { + case !c.AccessKey.WasSet && !c.SecretKey.WasSet && !c.IAMRole.WasSet: + return nil, fmt.Errorf("error parsing arguments: the --access-key and --secret-key flags or the --iam-role flag must be provided") + case (c.AccessKey.WasSet || c.SecretKey.WasSet) && c.IAMRole.WasSet: + // Enforce mutual exclusion + return nil, fmt.Errorf("error parsing arguments: the --access-key and --secret-key flags are mutually exclusive with the --iam-role flag") + case c.AccessKey.WasSet && !c.SecretKey.WasSet: + return nil, fmt.Errorf("error parsing arguments: required flag --secret-key not provided") + case !c.AccessKey.WasSet && c.SecretKey.WasSet: + return nil, fmt.Errorf("error parsing arguments: required flag --access-key not provided") + } + + // The following blocks enforces the mutual exclusivity of the + // CompressionCodec and GzipLevel flags. + if c.CompressionCodec.WasSet && c.GzipLevel.WasSet { + return nil, fmt.Errorf("error parsing arguments: the --compression-codec flag is mutually exclusive with the --gzip-level flag") + } + + if c.AccessKey.WasSet { + input.AccessKey = &c.AccessKey.Value + } + + if c.SecretKey.WasSet { + input.SecretKey = &c.SecretKey.Value + } + + if c.IAMRole.WasSet { + input.IAMRole = &c.IAMRole.Value + } + + if c.Domain.WasSet { + input.Domain = &c.Domain.Value + } + + if c.FileMaxBytes.WasSet { + input.FileMaxBytes = &c.FileMaxBytes.Value + } + + if c.Path.WasSet { + input.Path = &c.Path.Value + } + + if c.Period.WasSet { + input.Period = &c.Period.Value + } + + if c.GzipLevel.WasSet { + input.GzipLevel = &c.GzipLevel.Value + } + + if c.Format.WasSet { + input.Format = fastly.ToPointer(argparser.Content(c.Format.Value)) + } + + if c.FormatVersion.WasSet { + input.FormatVersion = &c.FormatVersion.Value + } + + if c.MessageType.WasSet { + input.MessageType = &c.MessageType.Value + } + + if c.ResponseCondition.WasSet { + input.ResponseCondition = &c.ResponseCondition.Value + } + + if c.TimestampFormat.WasSet { + input.TimestampFormat = &c.TimestampFormat.Value + } + + if c.Placement.WasSet { + input.Placement = &c.Placement.Value + } + + if c.PublicKey.WasSet { + input.PublicKey = &c.PublicKey.Value + } + + if c.ServerSideEncryptionKMSKeyID.WasSet { + input.ServerSideEncryptionKMSKeyID = &c.ServerSideEncryptionKMSKeyID.Value + } + + if c.CompressionCodec.WasSet { + input.CompressionCodec = &c.CompressionCodec.Value + } + + if c.Redundancy.WasSet { + redundancy, err := ValidateRedundancy(c.Redundancy.Value) + if err == nil { + input.Redundancy = &redundancy + } + } + + if c.ServerSideEncryption.WasSet { + switch c.ServerSideEncryption.Value { + case string(fastly.S3ServerSideEncryptionAES): + sse := fastly.S3ServerSideEncryptionAES + input.ServerSideEncryption = &sse + case string(fastly.S3ServerSideEncryptionKMS): + sse := fastly.S3ServerSideEncryptionKMS + input.ServerSideEncryption = &sse + } + } + + return &input, nil +} + +// ValidateRedundancy identifies the given redundancy type. +func ValidateRedundancy(val string) (redundancy fastly.S3Redundancy, err error) { + switch val { + case string(fastly.S3RedundancyStandard): + redundancy = fastly.S3RedundancyStandard + case string(fastly.S3RedundancyIntelligentTiering): + redundancy = fastly.S3RedundancyIntelligentTiering + case string(fastly.S3RedundancyStandardIA): + redundancy = fastly.S3RedundancyStandardIA + case string(fastly.S3RedundancyOneZoneIA): + redundancy = fastly.S3RedundancyOneZoneIA + case string(fastly.S3RedundancyGlacierInstantRetrieval): + redundancy = fastly.S3RedundancyGlacierInstantRetrieval + case string(fastly.S3RedundancyGlacierFlexibleRetrieval): + redundancy = fastly.S3RedundancyGlacierFlexibleRetrieval + case string(fastly.S3RedundancyGlacierDeepArchive): + redundancy = fastly.S3RedundancyGlacierDeepArchive + case string(fastly.S3RedundancyReduced): + redundancy = fastly.S3RedundancyReduced + default: + err = fmt.Errorf("unknown redundancy: %s", val) + } + return redundancy, err +} + +// Exec invokes the application logic for the command. +func (c *CreateCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.AutoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.ServiceName, + ServiceVersionFlag: c.ServiceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + input, err := c.ConstructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + d, err := c.Globals.APIClient.CreateS3(input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + text.Success(out, + "Created S3 logging endpoint %s (service %s version %d)", + fastly.ToValue(d.Name), + fastly.ToValue(d.ServiceID), + fastly.ToValue(d.ServiceVersion), + ) + return nil +} diff --git a/pkg/commands/logging/s3/delete.go b/pkg/commands/logging/s3/delete.go new file mode 100644 index 000000000..ce3ef477b --- /dev/null +++ b/pkg/commands/logging/s3/delete.go @@ -0,0 +1,94 @@ +package s3 + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// DeleteCommand calls the Fastly API to delete an Amazon S3 logging endpoint. +type DeleteCommand struct { + argparser.Base + Input fastly.DeleteS3Input + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion + autoClone argparser.OptionalAutoClone +} + +// NewDeleteCommand returns a usable command registered under the parent. +func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { + c := DeleteCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("delete", "Delete a S3 logging endpoint on a Fastly service version").Alias("remove") + + // Required. + c.CmdClause.Flag("name", "The name of the S3 logging object").Short('n').Required().StringVar(&c.Input.Name) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.autoClone.Set, + Dst: &c.autoClone.Value, + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + return &c +} + +// Exec invokes the application logic for the command. +func (c *DeleteCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.autoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + c.Input.ServiceID = serviceID + c.Input.ServiceVersion = fastly.ToValue(serviceVersion.Number) + + if err := c.Globals.APIClient.DeleteS3(&c.Input); err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + text.Success(out, "Deleted S3 logging endpoint %s (service %s version %d)", c.Input.Name, c.Input.ServiceID, c.Input.ServiceVersion) + return nil +} diff --git a/pkg/commands/logging/s3/describe.go b/pkg/commands/logging/s3/describe.go new file mode 100644 index 000000000..9b16ee1c9 --- /dev/null +++ b/pkg/commands/logging/s3/describe.go @@ -0,0 +1,127 @@ +package s3 + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// DescribeCommand calls the Fastly API to describe an Amazon S3 logging endpoint. +type DescribeCommand struct { + argparser.Base + argparser.JSONOutput + + Input fastly.GetS3Input + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion +} + +// NewDescribeCommand returns a usable command registered under the parent. +func NewDescribeCommand(parent argparser.Registerer, g *global.Data) *DescribeCommand { + c := DescribeCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("describe", "Show detailed information about a S3 logging endpoint on a Fastly service version").Alias("get") + + // Required. + c.CmdClause.Flag("name", "The name of the S3 logging object").Short('n').Required().StringVar(&c.Input.Name) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + return &c +} + +// Exec invokes the application logic for the command. +func (c *DescribeCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fsterr.ServiceVersion(serviceVersion), + }) + return err + } + + c.Input.ServiceID = serviceID + c.Input.ServiceVersion = fastly.ToValue(serviceVersion.Number) + + o, err := c.Globals.APIClient.GetS3(&c.Input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + lines := text.Lines{ + "Bucket": fastly.ToValue(o.BucketName), + "Compression codec": fastly.ToValue(o.CompressionCodec), + "File max bytes": fastly.ToValue(o.FileMaxBytes), + "Format version": fastly.ToValue(o.FormatVersion), + "Format": fastly.ToValue(o.Format), + "GZip level": fastly.ToValue(o.GzipLevel), + "Message type": fastly.ToValue(o.MessageType), + "Name": fastly.ToValue(o.Name), + "Path": fastly.ToValue(o.Path), + "Period": fastly.ToValue(o.Period), + "Placement": fastly.ToValue(o.Placement), + "Public key": fastly.ToValue(o.PublicKey), + "Redundancy": fastly.ToValue(o.Redundancy), + "Response condition": fastly.ToValue(o.ResponseCondition), + "Server-side encryption KMS key ID": fastly.ToValue(o.ServerSideEncryption), + "Server-side encryption": fastly.ToValue(o.ServerSideEncryption), + "Timestamp format": fastly.ToValue(o.TimestampFormat), + "Version": fastly.ToValue(o.ServiceVersion), + } + if o.AccessKey != nil || o.SecretKey != nil { + lines["Access key"] = fastly.ToValue(o.AccessKey) + lines["Secret key"] = fastly.ToValue(o.SecretKey) + } + if o.IAMRole != nil { + lines["IAM role"] = fastly.ToValue(o.IAMRole) + } + if !c.Globals.Verbose() { + lines["Service ID"] = fastly.ToValue(o.ServiceID) + } + text.PrintLines(out, lines) + + return nil +} diff --git a/pkg/logging/s3/doc.go b/pkg/commands/logging/s3/doc.go similarity index 100% rename from pkg/logging/s3/doc.go rename to pkg/commands/logging/s3/doc.go diff --git a/pkg/commands/logging/s3/list.go b/pkg/commands/logging/s3/list.go new file mode 100644 index 000000000..472b74cff --- /dev/null +++ b/pkg/commands/logging/s3/list.go @@ -0,0 +1,141 @@ +package s3 + +import ( + "fmt" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// ListCommand calls the Fastly API to list Amazon S3 logging endpoints. +type ListCommand struct { + argparser.Base + argparser.JSONOutput + + Input fastly.ListS3sInput + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion +} + +// NewListCommand returns a usable command registered under the parent. +func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { + c := ListCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("list", "List S3 endpoints on a Fastly service version") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + return &c +} + +// Exec invokes the application logic for the command. +func (c *ListCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fsterr.ServiceVersion(serviceVersion), + }) + return err + } + + c.Input.ServiceID = serviceID + c.Input.ServiceVersion = fastly.ToValue(serviceVersion.Number) + + o, err := c.Globals.APIClient.ListS3s(&c.Input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + if !c.Globals.Verbose() { + tw := text.NewTable(out) + tw.AddHeader("SERVICE", "VERSION", "NAME") + for _, s3 := range o { + tw.AddLine( + fastly.ToValue(s3.ServiceID), + fastly.ToValue(s3.ServiceVersion), + fastly.ToValue(s3.Name), + ) + } + tw.Print() + return nil + } + + fmt.Fprintf(out, "Version: %d\n", c.Input.ServiceVersion) + for i, s3 := range o { + fmt.Fprintf(out, "\tS3 %d/%d\n", i+1, len(o)) + fmt.Fprintf(out, "\t\tService ID: %s\n", fastly.ToValue(s3.ServiceID)) + fmt.Fprintf(out, "\t\tVersion: %d\n", fastly.ToValue(s3.ServiceVersion)) + fmt.Fprintf(out, "\t\tName: %s\n", fastly.ToValue(s3.Name)) + fmt.Fprintf(out, "\t\tBucket: %s\n", fastly.ToValue(s3.BucketName)) + if s3.AccessKey != nil || s3.SecretKey != nil { + fmt.Fprintf(out, "\t\tAccess key: %s\n", fastly.ToValue(s3.AccessKey)) + fmt.Fprintf(out, "\t\tSecret key: %s\n", fastly.ToValue(s3.SecretKey)) + } + if s3.IAMRole != nil { + fmt.Fprintf(out, "\t\tIAM role: %s\n", fastly.ToValue(s3.IAMRole)) + } + fmt.Fprintf(out, "\t\tPath: %s\n", fastly.ToValue(s3.Path)) + fmt.Fprintf(out, "\t\tPeriod: %d\n", fastly.ToValue(s3.Period)) + fmt.Fprintf(out, "\t\tGZip level: %d\n", fastly.ToValue(s3.GzipLevel)) + fmt.Fprintf(out, "\t\tFormat: %s\n", fastly.ToValue(s3.Format)) + fmt.Fprintf(out, "\t\tFormat version: %d\n", fastly.ToValue(s3.FormatVersion)) + fmt.Fprintf(out, "\t\tResponse condition: %s\n", fastly.ToValue(s3.ResponseCondition)) + fmt.Fprintf(out, "\t\tMessage type: %s\n", fastly.ToValue(s3.MessageType)) + fmt.Fprintf(out, "\t\tTimestamp format: %s\n", fastly.ToValue(s3.TimestampFormat)) + fmt.Fprintf(out, "\t\tPlacement: %s\n", fastly.ToValue(s3.Placement)) + fmt.Fprintf(out, "\t\tPublic key: %s\n", fastly.ToValue(s3.PublicKey)) + fmt.Fprintf(out, "\t\tRedundancy: %s\n", fastly.ToValue(s3.Redundancy)) + fmt.Fprintf(out, "\t\tServer-side encryption: %s\n", fastly.ToValue(s3.ServerSideEncryption)) + fmt.Fprintf(out, "\t\tServer-side encryption KMS key ID: %s\n", fastly.ToValue(s3.ServerSideEncryption)) + fmt.Fprintf(out, "\t\tFile max bytes: %d\n", fastly.ToValue(s3.FileMaxBytes)) + fmt.Fprintf(out, "\t\tCompression codec: %s\n", fastly.ToValue(s3.CompressionCodec)) + } + fmt.Fprintln(out) + + return nil +} diff --git a/pkg/commands/logging/s3/root.go b/pkg/commands/logging/s3/root.go new file mode 100644 index 000000000..70d61fc2c --- /dev/null +++ b/pkg/commands/logging/s3/root.go @@ -0,0 +1,31 @@ +package s3 + +import ( + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" +) + +// RootCommand is the parent command for all subcommands in this package. +// It should be installed under the primary root command. +type RootCommand struct { + argparser.Base + // no flags +} + +// CommandName is the string to be used to invoke this command. +const CommandName = "s3" + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent argparser.Registerer, g *global.Data) *RootCommand { + var c RootCommand + c.Globals = g + c.CmdClause = parent.Command(CommandName, "Manipulate Fastly service version S3 logging endpoints") + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { + panic("unreachable") +} diff --git a/pkg/commands/logging/s3/s3_integration_test.go b/pkg/commands/logging/s3/s3_integration_test.go new file mode 100644 index 000000000..6c47c54d9 --- /dev/null +++ b/pkg/commands/logging/s3/s3_integration_test.go @@ -0,0 +1,592 @@ +package s3_test + +import ( + "bytes" + "errors" + "io" + "strings" + "testing" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/app" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" +) + +func TestS3Create(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging s3 create --service-id 123 --version 1 --name log --bucket log --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + }, + wantError: "error parsing arguments: the --access-key and --secret-key flags or the --iam-role flag must be provided", + }, + { + args: args("logging s3 create --service-id 123 --version 1 --name log --bucket log --secret-key bar --iam-role arn:aws:iam::123456789012:role/S3Access --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + }, + wantError: "error parsing arguments: the --access-key and --secret-key flags are mutually exclusive with the --iam-role flag", + }, + { + args: args("logging s3 create --service-id 123 --version 1 --name log --bucket log --access-key foo --iam-role arn:aws:iam::123456789012:role/S3Access --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + }, + wantError: "error parsing arguments: the --access-key and --secret-key flags are mutually exclusive with the --iam-role flag", + }, + { + args: args("logging s3 create --service-id 123 --version 1 --name log --bucket log --access-key foo --secret-key bar --iam-role arn:aws:iam::123456789012:role/S3Access --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + }, + wantError: "error parsing arguments: the --access-key and --secret-key flags are mutually exclusive with the --iam-role flag", + }, + { + args: args("logging s3 create --service-id 123 --version 1 --name log --bucket log --access-key foo --secret-key bar --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + CreateS3Fn: createS3OK, + }, + wantOutput: "Created S3 logging endpoint log (service 123 version 4)", + }, + { + args: args("logging s3 create --service-id 123 --version 1 --name log --bucket log --access-key foo --secret-key bar --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + CreateS3Fn: createS3Error, + }, + wantError: errTest.Error(), + }, + { + args: args("logging s3 create --service-id 123 --version 1 --name log2 --bucket log --iam-role arn:aws:iam::123456789012:role/S3Access --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + CreateS3Fn: createS3OK, + }, + wantOutput: "Created S3 logging endpoint log2 (service 123 version 4)", + }, + { + args: args("logging s3 create --service-id 123 --version 1 --name log2 --bucket log --iam-role arn:aws:iam::123456789012:role/S3Access --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + CreateS3Fn: createS3Error, + }, + wantError: errTest.Error(), + }, + { + args: args("logging s3 create --service-id 123 --version 1 --name log --bucket log --iam-role arn:aws:iam::123456789012:role/S3Access --compression-codec zstd --gzip-level 9 --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + }, + wantError: "error parsing arguments: the --compression-codec flag is mutually exclusive with the --gzip-level flag", + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertStringContains(t, stdout.String(), testcase.wantOutput) + }) + } +} + +func TestS3List(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging s3 list --service-id 123 --version 1"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListS3sFn: listS3sOK, + }, + wantOutput: listS3sShortOutput, + }, + { + args: args("logging s3 list --service-id 123 --version 1 --verbose"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListS3sFn: listS3sOK, + }, + wantOutput: listS3sVerboseOutput, + }, + { + args: args("logging s3 list --service-id 123 --version 1 -v"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListS3sFn: listS3sOK, + }, + wantOutput: listS3sVerboseOutput, + }, + { + args: args("logging s3 --verbose list --service-id 123 --version 1"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListS3sFn: listS3sOK, + }, + wantOutput: listS3sVerboseOutput, + }, + { + args: args("logging -v s3 list --service-id 123 --version 1"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListS3sFn: listS3sOK, + }, + wantOutput: listS3sVerboseOutput, + }, + { + args: args("logging s3 list --service-id 123 --version 1"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListS3sFn: listS3sError, + }, + wantError: errTest.Error(), + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertString(t, testcase.wantOutput, stdout.String()) + }) + } +} + +func TestS3Describe(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging s3 describe --service-id 123 --version 1"), + wantError: "error parsing arguments: required flag --name not provided", + }, + { + args: args("logging s3 describe --service-id 123 --version 1 --name logs"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + GetS3Fn: getS3Error, + }, + wantError: errTest.Error(), + }, + { + args: args("logging s3 describe --service-id 123 --version 1 --name logs"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + GetS3Fn: getS3OK, + }, + wantOutput: describeS3Output, + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertString(t, testcase.wantOutput, stdout.String()) + }) + } +} + +func TestS3Update(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging s3 update --service-id 123 --version 1 --new-name log"), + wantError: "error parsing arguments: required flag --name not provided", + }, + { + args: args("logging s3 update --service-id 123 --version 1 --name logs --new-name log --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + UpdateS3Fn: updateS3Error, + }, + wantError: errTest.Error(), + }, + { + args: args("logging s3 update --service-id 123 --version 1 --name logs --new-name log --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + UpdateS3Fn: updateS3OK, + }, + wantOutput: "Updated S3 logging endpoint log (service 123 version 4)", + }, + { + args: args("logging s3 update --service-id 123 --version 1 --name logs --access-key foo --secret-key bar --iam-role --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + UpdateS3Fn: updateS3OK, + }, + wantOutput: "Updated S3 logging endpoint log (service 123 version 4)", + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertStringContains(t, stdout.String(), testcase.wantOutput) + }) + } +} + +func TestS3Delete(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging s3 delete --service-id 123 --version 1"), + wantError: "error parsing arguments: required flag --name not provided", + }, + { + args: args("logging s3 delete --service-id 123 --version 1 --name logs --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + DeleteS3Fn: deleteS3Error, + }, + wantError: errTest.Error(), + }, + { + args: args("logging s3 delete --service-id 123 --version 1 --name logs --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + DeleteS3Fn: deleteS3OK, + }, + wantOutput: "Deleted S3 logging endpoint logs (service 123 version 4)", + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertStringContains(t, stdout.String(), testcase.wantOutput) + }) + } +} + +var errTest = errors.New("fixture error") + +func createS3OK(i *fastly.CreateS3Input) (*fastly.S3, error) { + return &fastly.S3{ + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: i.Name, + CompressionCodec: fastly.ToPointer("zstd"), + }, nil +} + +func createS3Error(_ *fastly.CreateS3Input) (*fastly.S3, error) { + return nil, errTest +} + +func listS3sOK(i *fastly.ListS3sInput) ([]*fastly.S3, error) { + return []*fastly.S3{ + { + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("logs"), + BucketName: fastly.ToPointer("my-logs"), + AccessKey: fastly.ToPointer("1234"), + SecretKey: fastly.ToPointer("-----BEGIN RSA PRIVATE KEY-----MIIEogIBAAKCA"), + IAMRole: fastly.ToPointer("xyz"), + Domain: fastly.ToPointer("https://s3.us-east-1.amazonaws.com"), + Path: fastly.ToPointer("logs/"), + Period: fastly.ToPointer(3600), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + FormatVersion: fastly.ToPointer(2), + MessageType: fastly.ToPointer("classic"), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + TimestampFormat: fastly.ToPointer("%Y-%m-%dT%H:%M:%S.000"), + Redundancy: fastly.ToPointer(fastly.S3RedundancyStandard), + Placement: fastly.ToPointer("none"), + PublicKey: fastly.ToPointer(pgpPublicKey()), + ServerSideEncryption: fastly.ToPointer(fastly.S3ServerSideEncryptionKMS), + ServerSideEncryptionKMSKeyID: fastly.ToPointer("1234"), + CompressionCodec: fastly.ToPointer("zstd"), + }, + { + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("analytics"), + BucketName: fastly.ToPointer("analytics"), + AccessKey: fastly.ToPointer("1234"), + SecretKey: fastly.ToPointer("-----BEGIN RSA PRIVATE KEY-----MIIEogIBAAKCA"), + Domain: fastly.ToPointer("https://s3.us-east-2.amazonaws.com"), + Path: fastly.ToPointer("logs/"), + Period: fastly.ToPointer(86400), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + FormatVersion: fastly.ToPointer(2), + MessageType: fastly.ToPointer("classic"), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + TimestampFormat: fastly.ToPointer("%Y-%m-%dT%H:%M:%S.000"), + Redundancy: fastly.ToPointer(fastly.S3RedundancyStandard), + Placement: fastly.ToPointer("none"), + PublicKey: fastly.ToPointer(pgpPublicKey()), + ServerSideEncryption: fastly.ToPointer(fastly.S3ServerSideEncryptionKMS), + ServerSideEncryptionKMSKeyID: fastly.ToPointer("1234"), + FileMaxBytes: fastly.ToPointer(12345), + CompressionCodec: fastly.ToPointer("zstd"), + }, + }, nil +} + +func listS3sError(_ *fastly.ListS3sInput) ([]*fastly.S3, error) { + return nil, errTest +} + +var listS3sShortOutput = strings.TrimSpace(` +SERVICE VERSION NAME +123 1 logs +123 1 analytics +`) + "\n" + +var listS3sVerboseOutput = strings.TrimSpace(` +Fastly API endpoint: https://api.fastly.com +Fastly API token provided via config file (profile: user) + +Service ID (via --service-id): 123 + +Version: 1 + S3 1/2 + Service ID: 123 + Version: 1 + Name: logs + Bucket: my-logs + Access key: 1234 + Secret key: -----BEGIN RSA PRIVATE KEY-----MIIEogIBAAKCA + IAM role: xyz + Path: logs/ + Period: 3600 + GZip level: 0 + Format: %h %l %u %t "%r" %>s %b + Format version: 2 + Response condition: Prevent default logging + Message type: classic + Timestamp format: %Y-%m-%dT%H:%M:%S.000 + Placement: none + Public key: `+pgpPublicKey()+` + Redundancy: standard + Server-side encryption: aws:kms + Server-side encryption KMS key ID: aws:kms + File max bytes: 0 + Compression codec: zstd + S3 2/2 + Service ID: 123 + Version: 1 + Name: analytics + Bucket: analytics + Access key: 1234 + Secret key: -----BEGIN RSA PRIVATE KEY-----MIIEogIBAAKCA + Path: logs/ + Period: 86400 + GZip level: 0 + Format: %h %l %u %t "%r" %>s %b + Format version: 2 + Response condition: Prevent default logging + Message type: classic + Timestamp format: %Y-%m-%dT%H:%M:%S.000 + Placement: none + Public key: `+pgpPublicKey()+` + Redundancy: standard + Server-side encryption: aws:kms + Server-side encryption KMS key ID: aws:kms + File max bytes: 12345 + Compression codec: zstd +`) + "\n\n" + +func getS3OK(i *fastly.GetS3Input) (*fastly.S3, error) { + return &fastly.S3{ + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("logs"), + BucketName: fastly.ToPointer("my-logs"), + AccessKey: fastly.ToPointer("1234"), + SecretKey: fastly.ToPointer("-----BEGIN RSA PRIVATE KEY-----MIIEogIBAAKCA"), + Domain: fastly.ToPointer("https://s3.us-east-1.amazonaws.com"), + Path: fastly.ToPointer("logs/"), + Period: fastly.ToPointer(3600), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + FormatVersion: fastly.ToPointer(2), + MessageType: fastly.ToPointer("classic"), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + TimestampFormat: fastly.ToPointer("%Y-%m-%dT%H:%M:%S.000"), + Redundancy: fastly.ToPointer(fastly.S3RedundancyStandard), + Placement: fastly.ToPointer("none"), + PublicKey: fastly.ToPointer(pgpPublicKey()), + ServerSideEncryption: fastly.ToPointer(fastly.S3ServerSideEncryptionKMS), + ServerSideEncryptionKMSKeyID: fastly.ToPointer("1234"), + CompressionCodec: fastly.ToPointer("zstd"), + }, nil +} + +func getS3Error(_ *fastly.GetS3Input) (*fastly.S3, error) { + return nil, errTest +} + +var describeS3Output = "\n" + strings.TrimSpace(` +Access key: 1234 +Bucket: my-logs +Compression codec: zstd +File max bytes: 0 +Format: %h %l %u %t "%r" %>s %b +Format version: 2 +GZip level: 0 +Message type: classic +Name: logs +Path: logs/ +Period: 3600 +Placement: none +Public key: `+pgpPublicKey()+` +Redundancy: standard +Response condition: Prevent default logging +Secret key: -----BEGIN RSA PRIVATE KEY-----MIIEogIBAAKCA +Server-side encryption: aws:kms +Server-side encryption KMS key ID: aws:kms +Service ID: 123 +Timestamp format: %Y-%m-%dT%H:%M:%S.000 +Version: 1 +`) + "\n" + +func updateS3OK(i *fastly.UpdateS3Input) (*fastly.S3, error) { + return &fastly.S3{ + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("log"), + BucketName: fastly.ToPointer("my-logs"), + AccessKey: fastly.ToPointer("1234"), + SecretKey: fastly.ToPointer("-----BEGIN RSA PRIVATE KEY-----MIIEogIBAAKCA"), + Domain: fastly.ToPointer("https://s3.us-east-1.amazonaws.com"), + Path: fastly.ToPointer("logs/"), + Period: fastly.ToPointer(3600), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + FormatVersion: fastly.ToPointer(2), + MessageType: fastly.ToPointer("classic"), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + TimestampFormat: fastly.ToPointer("%Y-%m-%dT%H:%M:%S.000"), + Redundancy: fastly.ToPointer(fastly.S3RedundancyStandard), + Placement: fastly.ToPointer("none"), + PublicKey: fastly.ToPointer(pgpPublicKey()), + ServerSideEncryption: fastly.ToPointer(fastly.S3ServerSideEncryptionKMS), + ServerSideEncryptionKMSKeyID: fastly.ToPointer("1234"), + CompressionCodec: fastly.ToPointer("zstd"), + }, nil +} + +func updateS3Error(_ *fastly.UpdateS3Input) (*fastly.S3, error) { + return nil, errTest +} + +func deleteS3OK(_ *fastly.DeleteS3Input) error { + return nil +} + +func deleteS3Error(_ *fastly.DeleteS3Input) error { + return errTest +} + +// pgpPublicKey returns a PEM encoded PGP public key suitable for testing. +func pgpPublicKey() string { + return strings.TrimSpace(`-----BEGIN PGP PUBLIC KEY BLOCK----- +mQENBFyUD8sBCACyFnB39AuuTygseek+eA4fo0cgwva6/FSjnWq7riouQee8GgQ/ +ibXTRyv4iVlwI12GswvMTIy7zNvs1R54i0qvsLr+IZ4GVGJqs6ZJnvQcqe3xPoR4 +8AnBfw90o32r/LuHf6QCJXi+AEu35koNlNAvLJ2B+KACaNB7N0EeWmqpV/1V2k9p +lDYk+th7LcCuaFNGqKS/PrMnnMqR6VDLCjHhNx4KR79b0Twm/2qp6an3hyNRu8Gn +dwxpf1/BUu3JWf+LqkN4Y3mbOmSUL3MaJNvyQguUzTfS0P0uGuBDHrJCVkMZCzDB +89ag55jCPHyGeHBTd02gHMWzsg3WMBWvCsrzABEBAAG0JXRlcnJhZm9ybSAodGVz +dCkgPHRlc3RAdGVycmFmb3JtLmNvbT6JAU4EEwEIADgWIQSHYyc6Kj9l6HzQsau6 +vFFc9jxV/wUCXJQPywIbAwULCQgHAgYVCgkICwIEFgIDAQIeAQIXgAAKCRC6vFFc +9jxV/815CAClb32OxV7wG01yF97TzlyTl8TnvjMtoG29Mw4nSyg+mjM3b8N7iXm9 +OLX59fbDAWtBSldSZE22RXd3CvlFOG/EnKBXSjBtEqfyxYSnyOPkMPBYWGL/ApkX +SvPYJ4LKdvipYToKFh3y9kk2gk1DcDBDyaaHvR+3rv1u3aoy7/s2EltAfDS3ZQIq +7/cWTLJml/lleeB/Y6rPj8xqeCYhE5ahw9gsV/Mdqatl24V9Tks30iijx0Hhw+Gx +kATUikMGr2GDVqoIRga5kXI7CzYff4rkc0Twn47fMHHHe/KY9M2yVnMHUXmAZwbG +M1cMI/NH1DjevCKdGBLcRJlhuLPKF/anuQENBFyUD8sBCADIpd7r7GuPd6n/Ikxe +u6h7umV6IIPoAm88xCYpTbSZiaK30Svh6Ywra9jfE2KlU9o6Y/art8ip0VJ3m07L +4RSfSpnzqgSwdjSq5hNour2Fo/BzYhK7yaz2AzVSbe33R0+RYhb4b/6N+bKbjwGF +ftCsqVFMH+PyvYkLbvxyQrHlA9woAZaNThI1ztO5rGSnGUR8xt84eup28WIFKg0K +UEGUcTzz+8QGAwAra+0ewPXo/AkO+8BvZjDidP417u6gpBHOJ9qYIcO9FxHeqFyu +YrjlrxowEgXn5wO8xuNz6Vu1vhHGDHGDsRbZF8pv1d5O+0F1G7ttZ2GRRgVBZPwi +kiyRABEBAAGJATYEGAEIACAWIQSHYyc6Kj9l6HzQsau6vFFc9jxV/wUCXJQPywIb +DAAKCRC6vFFc9jxV/9YOCACe8qmOSnKQpQfW+PqYOqo3dt7JyweTs3FkD6NT8Zml +dYy/vkstbTjPpX6aTvUZjkb46BVi7AOneVHpD5GBqvRsZ9iVgDYHaehmLCdKiG5L +3Tp90NN+QY5WDbsGmsyk6+6ZMYejb4qYfweQeduOj27aavCJdLkCYMoRKfcFYI8c +FaNmEfKKy/r1PO20NXEG6t9t05K/frHy6ZG8bCNYdpagfFVot47r9JaQqWlTNtIR +5+zkkSq/eG9BEtRij3a6cTdQbktdBzx2KBeI0PYc1vlZR0LpuFKZqY9vlE6vTGLR +wMfrTEOvx0NxUM3rpaCgEmuWbB1G1Hu371oyr4srrr+N +=28dr +-----END PGP PUBLIC KEY BLOCK----- +`) +} diff --git a/pkg/commands/logging/s3/s3_test.go b/pkg/commands/logging/s3/s3_test.go new file mode 100644 index 000000000..9630f2bcb --- /dev/null +++ b/pkg/commands/logging/s3/s3_test.go @@ -0,0 +1,478 @@ +package s3_test + +import ( + "bytes" + "testing" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/logging/s3" + "github.com/fastly/cli/pkg/config" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" +) + +func TestCreateS3Input(t *testing.T) { + red := fastly.S3RedundancyStandard + sse := fastly.S3ServerSideEncryptionAES + for _, testcase := range []struct { + name string + cmd *s3.CreateCommand + want *fastly.CreateS3Input + wantError string + }{ + { + name: "required values set flag serviceID using access credentials", + cmd: createCommandRequired(), + want: &fastly.CreateS3Input{ + ServiceID: "123", + ServiceVersion: 4, + Name: fastly.ToPointer("log"), + BucketName: fastly.ToPointer("bucket"), + AccessKey: fastly.ToPointer("access"), + SecretKey: fastly.ToPointer("secret"), + }, + }, + { + name: "required values set flag serviceID using IAM role", + cmd: createCommandRequiredIAMRole(), + want: &fastly.CreateS3Input{ + ServiceID: "123", + ServiceVersion: 4, + Name: fastly.ToPointer("log"), + BucketName: fastly.ToPointer("bucket"), + IAMRole: fastly.ToPointer("arn:aws:iam::123456789012:role/S3Access"), + }, + }, + { + name: "all values set flag serviceID", + cmd: createCommandAll(), + want: &fastly.CreateS3Input{ + ServiceID: "123", + ServiceVersion: 4, + Name: fastly.ToPointer("logs"), + BucketName: fastly.ToPointer("bucket"), + Domain: fastly.ToPointer("domain"), + AccessKey: fastly.ToPointer("access"), + SecretKey: fastly.ToPointer("secret"), + Path: fastly.ToPointer("path"), + Period: fastly.ToPointer(3600), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + MessageType: fastly.ToPointer("classic"), + FormatVersion: fastly.ToPointer(2), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + TimestampFormat: fastly.ToPointer("%Y-%m-%dT%H:%M:%S.000"), + Redundancy: &red, + Placement: fastly.ToPointer("none"), + PublicKey: fastly.ToPointer(pgpPublicKey()), + ServerSideEncryptionKMSKeyID: fastly.ToPointer("kmskey"), + ServerSideEncryption: &sse, + CompressionCodec: fastly.ToPointer("zstd"), + }, + }, + { + name: "error missing serviceID", + cmd: createCommandMissingServiceID(), + want: nil, + wantError: errors.ErrNoServiceID.Error(), + }, + } { + t.Run(testcase.name, func(t *testing.T) { + var bs []byte + out := bytes.NewBuffer(bs) + verboseMode := true + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + AutoCloneFlag: testcase.cmd.AutoClone, + APIClient: testcase.cmd.Globals.APIClient, + Manifest: testcase.cmd.Manifest, + Out: out, + ServiceVersionFlag: testcase.cmd.ServiceVersion, + VerboseMode: verboseMode, + }) + + switch { + case err != nil && testcase.wantError == "": + t.Fatalf("unexpected error getting service details: %v", err) + return + case err != nil && testcase.wantError != "": + testutil.AssertErrorContains(t, err, testcase.wantError) + return + case err == nil && testcase.wantError != "": + t.Fatalf("expected error, have nil (service details: %s, %d)", serviceID, serviceVersion.Number) + case err == nil && testcase.wantError == "": + have, err := testcase.cmd.ConstructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertEqual(t, testcase.want, have) + } + }) + } +} + +func TestUpdateS3Input(t *testing.T) { + scenarios := []struct { + name string + cmd *s3.UpdateCommand + api mock.API + want *fastly.UpdateS3Input + wantError string + }{ + { + name: "no updates", + cmd: updateCommandNoUpdates(), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + GetS3Fn: getS3OK, + }, + want: &fastly.UpdateS3Input{ + ServiceID: "123", + ServiceVersion: 4, + Name: "log", + }, + }, + { + name: "all values set flag serviceID", + cmd: updateCommandAll(), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + GetS3Fn: getS3OK, + }, + want: &fastly.UpdateS3Input{ + ServiceID: "123", + ServiceVersion: 4, + Name: "log", + NewName: fastly.ToPointer("new1"), + BucketName: fastly.ToPointer("new2"), + AccessKey: fastly.ToPointer("new3"), + SecretKey: fastly.ToPointer("new4"), + IAMRole: fastly.ToPointer(""), + Domain: fastly.ToPointer("new5"), + Path: fastly.ToPointer("new6"), + Period: fastly.ToPointer(3601), + GzipLevel: fastly.ToPointer(0), + Format: fastly.ToPointer("new7"), + FormatVersion: fastly.ToPointer(3), + MessageType: fastly.ToPointer("new8"), + ResponseCondition: fastly.ToPointer("new9"), + TimestampFormat: fastly.ToPointer("new10"), + Placement: fastly.ToPointer("new11"), + Redundancy: fastly.ToPointer(fastly.S3RedundancyReduced), + ServerSideEncryption: fastly.ToPointer(fastly.S3ServerSideEncryptionKMS), + ServerSideEncryptionKMSKeyID: fastly.ToPointer("new12"), + PublicKey: fastly.ToPointer("new13"), + CompressionCodec: fastly.ToPointer("new14"), + }, + }, + { + name: "error missing serviceID", + cmd: updateCommandMissingServiceID(), + want: nil, + wantError: errors.ErrNoServiceID.Error(), + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(testcase.name, func(t *testing.T) { + testcase.cmd.Globals.APIClient = testcase.api + + var bs []byte + out := bytes.NewBuffer(bs) + verboseMode := true + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + AutoCloneFlag: testcase.cmd.AutoClone, + APIClient: testcase.api, + Manifest: testcase.cmd.Manifest, + Out: out, + ServiceVersionFlag: testcase.cmd.ServiceVersion, + VerboseMode: verboseMode, + }) + + switch { + case err != nil && testcase.wantError == "": + t.Fatalf("unexpected error getting service details: %v", err) + return + case err != nil && testcase.wantError != "": + testutil.AssertErrorContains(t, err, testcase.wantError) + return + case err == nil && testcase.wantError != "": + t.Fatalf("expected error, have nil (service details: %s, %d)", serviceID, serviceVersion.Number) + case err == nil && testcase.wantError == "": + have, err := testcase.cmd.ConstructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertEqual(t, testcase.want, have) + } + }) + } +} + +func createCommandRequired() *s3.CreateCommand { + var b bytes.Buffer + + g := global.Data{ + Config: config.File{}, + Env: config.Environment{}, + Output: &b, + } + g.APIClient, _ = mock.APIClient(mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + })("token", "endpoint", false) + + return &s3.CreateCommand{ + Base: argparser.Base{ + Globals: &g, + }, + Manifest: manifest.Data{ + Flag: manifest.Flag{ + ServiceID: "123", + }, + }, + ServiceVersion: argparser.OptionalServiceVersion{ + OptionalString: argparser.OptionalString{Value: "1"}, + }, + AutoClone: argparser.OptionalAutoClone{ + OptionalBool: argparser.OptionalBool{ + Optional: argparser.Optional{ + WasSet: true, + }, + Value: true, + }, + }, + EndpointName: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "log"}, + BucketName: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "bucket"}, + AccessKey: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "access"}, + SecretKey: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "secret"}, + } +} + +func createCommandRequiredIAMRole() *s3.CreateCommand { + var b bytes.Buffer + + g := global.Data{ + Config: config.File{}, + Env: config.Environment{}, + Output: &b, + } + g.APIClient, _ = mock.APIClient(mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + })("token", "endpoint", false) + + return &s3.CreateCommand{ + Base: argparser.Base{ + Globals: &g, + }, + Manifest: manifest.Data{ + Flag: manifest.Flag{ + ServiceID: "123", + }, + }, + ServiceVersion: argparser.OptionalServiceVersion{ + OptionalString: argparser.OptionalString{Value: "1"}, + }, + AutoClone: argparser.OptionalAutoClone{ + OptionalBool: argparser.OptionalBool{ + Optional: argparser.Optional{ + WasSet: true, + }, + Value: true, + }, + }, + EndpointName: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "log"}, + BucketName: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "bucket"}, + IAMRole: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "arn:aws:iam::123456789012:role/S3Access"}, + } +} + +func createCommandAll() *s3.CreateCommand { + var b bytes.Buffer + + g := global.Data{ + Config: config.File{}, + Env: config.Environment{}, + Output: &b, + } + g.APIClient, _ = mock.APIClient(mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + })("token", "endpoint", false) + + return &s3.CreateCommand{ + Base: argparser.Base{ + Globals: &g, + }, + Manifest: manifest.Data{ + Flag: manifest.Flag{ + ServiceID: "123", + }, + }, + ServiceVersion: argparser.OptionalServiceVersion{ + OptionalString: argparser.OptionalString{Value: "1"}, + }, + AutoClone: argparser.OptionalAutoClone{ + OptionalBool: argparser.OptionalBool{ + Optional: argparser.Optional{ + WasSet: true, + }, + Value: true, + }, + }, + EndpointName: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "logs"}, + BucketName: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "bucket"}, + AccessKey: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "access"}, + SecretKey: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "secret"}, + Domain: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "domain"}, + Path: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "path"}, + Period: argparser.OptionalInt{Optional: argparser.Optional{WasSet: true}, Value: 3600}, + Format: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: `%h %l %u %t "%r" %>s %b`}, + FormatVersion: argparser.OptionalInt{Optional: argparser.Optional{WasSet: true}, Value: 2}, + MessageType: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "classic"}, + ResponseCondition: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "Prevent default logging"}, + TimestampFormat: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "%Y-%m-%dT%H:%M:%S.000"}, + Placement: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "none"}, + PublicKey: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: pgpPublicKey()}, + Redundancy: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: string(fastly.S3RedundancyStandard)}, + ServerSideEncryption: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: string(fastly.S3ServerSideEncryptionAES)}, + ServerSideEncryptionKMSKeyID: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "kmskey"}, + CompressionCodec: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "zstd"}, + } +} + +func createCommandMissingServiceID() *s3.CreateCommand { + res := createCommandAll() + res.Manifest = manifest.Data{} + return res +} + +func updateCommandNoUpdates() *s3.UpdateCommand { + var b bytes.Buffer + + g := global.Data{ + Config: config.File{}, + Env: config.Environment{}, + Output: &b, + } + + return &s3.UpdateCommand{ + Base: argparser.Base{ + Globals: &g, + }, + Manifest: manifest.Data{ + Flag: manifest.Flag{ + ServiceID: "123", + }, + }, + EndpointName: "log", + ServiceVersion: argparser.OptionalServiceVersion{ + OptionalString: argparser.OptionalString{Value: "1"}, + }, + AutoClone: argparser.OptionalAutoClone{ + OptionalBool: argparser.OptionalBool{ + Optional: argparser.Optional{ + WasSet: true, + }, + Value: true, + }, + }, + } +} + +func updateCommandAll() *s3.UpdateCommand { + var b bytes.Buffer + + g := global.Data{ + Config: config.File{}, + Env: config.Environment{}, + Output: &b, + } + + return &s3.UpdateCommand{ + Base: argparser.Base{ + Globals: &g, + }, + Manifest: manifest.Data{ + Flag: manifest.Flag{ + ServiceID: "123", + }, + }, + EndpointName: "log", + ServiceVersion: argparser.OptionalServiceVersion{ + OptionalString: argparser.OptionalString{Value: "1"}, + }, + AutoClone: argparser.OptionalAutoClone{ + OptionalBool: argparser.OptionalBool{ + Optional: argparser.Optional{ + WasSet: true, + }, + Value: true, + }, + }, + NewName: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new1"}, + BucketName: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new2"}, + AccessKey: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new3"}, + SecretKey: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new4"}, + IAMRole: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: ""}, + Domain: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new5"}, + Path: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new6"}, + Period: argparser.OptionalInt{Optional: argparser.Optional{WasSet: true}, Value: 3601}, + GzipLevel: argparser.OptionalInt{Optional: argparser.Optional{WasSet: true}, Value: 0}, + Format: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new7"}, + FormatVersion: argparser.OptionalInt{Optional: argparser.Optional{WasSet: true}, Value: 3}, + MessageType: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new8"}, + ResponseCondition: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new9"}, + TimestampFormat: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new10"}, + Placement: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new11"}, + Redundancy: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: string(fastly.S3RedundancyReduced)}, + ServerSideEncryption: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: string(fastly.S3ServerSideEncryptionKMS)}, + ServerSideEncryptionKMSKeyID: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new12"}, + PublicKey: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new13"}, + CompressionCodec: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new14"}, + } +} + +func updateCommandMissingServiceID() *s3.UpdateCommand { + res := updateCommandAll() + res.Manifest = manifest.Data{} + return res +} + +func TestValidateRedundancy(t *testing.T) { + for _, testcase := range []struct { + value string + want fastly.S3Redundancy + wantError string + }{ + {value: "standard", want: fastly.S3RedundancyStandard}, + {value: "standard_ia", want: fastly.S3RedundancyStandardIA}, + {value: "onezone_ia", want: fastly.S3RedundancyOneZoneIA}, + {value: "glacier", want: fastly.S3RedundancyGlacierFlexibleRetrieval}, + {value: "glacier_ir", want: fastly.S3RedundancyGlacierInstantRetrieval}, + {value: "deep_archive", want: fastly.S3RedundancyGlacierDeepArchive}, + {value: "reduced_redundancy", want: fastly.S3RedundancyReduced}, + {value: "bad_value", wantError: "unknown redundancy"}, + } { + t.Run(testcase.value, func(t *testing.T) { + have, err := s3.ValidateRedundancy(testcase.value) + + switch { + case err != nil && testcase.wantError == "": + t.Fatalf("unexpected error ValidateRedundancy: %v", err) + return + case err != nil && testcase.wantError != "": + testutil.AssertErrorContains(t, err, testcase.wantError) + return + case err == nil && testcase.wantError != "": + t.Fatalf("expected error, have nil (redundancy: %s)", testcase.value) + case err == nil && testcase.wantError == "": + testutil.AssertEqual(t, testcase.want, have) + } + }) + } +} diff --git a/pkg/commands/logging/s3/update.go b/pkg/commands/logging/s3/update.go new file mode 100644 index 000000000..b6c81fdd9 --- /dev/null +++ b/pkg/commands/logging/s3/update.go @@ -0,0 +1,256 @@ +package s3 + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/logging/common" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/text" +) + +// UpdateCommand calls the Fastly API to update an Amazon S3 logging endpoint. +type UpdateCommand struct { + argparser.Base + Manifest manifest.Data + + // Required. + EndpointName string // Can't shadow argparser.Base method Name(). + ServiceName argparser.OptionalServiceNameID + ServiceVersion argparser.OptionalServiceVersion + + // Optional. + AutoClone argparser.OptionalAutoClone + NewName argparser.OptionalString + Address argparser.OptionalString + BucketName argparser.OptionalString + AccessKey argparser.OptionalString + SecretKey argparser.OptionalString + IAMRole argparser.OptionalString + Domain argparser.OptionalString + Path argparser.OptionalString + Period argparser.OptionalInt + GzipLevel argparser.OptionalInt + FileMaxBytes argparser.OptionalInt + Format argparser.OptionalString + FormatVersion argparser.OptionalInt + MessageType argparser.OptionalString + ResponseCondition argparser.OptionalString + TimestampFormat argparser.OptionalString + Placement argparser.OptionalString + PublicKey argparser.OptionalString + Redundancy argparser.OptionalString + ServerSideEncryption argparser.OptionalString + ServerSideEncryptionKMSKeyID argparser.OptionalString + CompressionCodec argparser.OptionalString +} + +// NewUpdateCommand returns a usable command registered under the parent. +func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { + c := UpdateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("update", "Update a S3 logging endpoint on a Fastly service version") + + // Required. + c.CmdClause.Flag("name", "The name of the S3 logging object").Short('n').Required().StringVar(&c.EndpointName) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.ServiceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.AutoClone.Set, + Dst: &c.AutoClone.Value, + }) + c.CmdClause.Flag("access-key", "Your S3 account access key").Action(c.AccessKey.Set).StringVar(&c.AccessKey.Value) + c.CmdClause.Flag("bucket", "Your S3 bucket name").Action(c.BucketName.Set).StringVar(&c.BucketName.Value) + common.CompressionCodec(c.CmdClause, &c.CompressionCodec) + c.CmdClause.Flag("domain", "The domain of the S3 endpoint").Action(c.Domain.Set).StringVar(&c.Domain.Value) + c.CmdClause.Flag("file-max-bytes", "The maximum size of a log file in bytes").Action(c.FileMaxBytes.Set).IntVar(&c.FileMaxBytes.Value) + common.Format(c.CmdClause, &c.Format) + common.FormatVersion(c.CmdClause, &c.FormatVersion) + common.GzipLevel(c.CmdClause, &c.GzipLevel) + c.CmdClause.Flag("iam-role", "The IAM role ARN for logging").Action(c.IAMRole.Set).StringVar(&c.IAMRole.Value) + common.MessageType(c.CmdClause, &c.MessageType) + c.CmdClause.Flag("new-name", "New name of the S3 logging object").Action(c.NewName.Set).StringVar(&c.NewName.Value) + common.Path(c.CmdClause, &c.Path) + common.Period(c.CmdClause, &c.Period) + common.Placement(c.CmdClause, &c.Placement) + common.PublicKey(c.CmdClause, &c.PublicKey) + c.CmdClause.Flag("redundancy", "The S3 storage class. One of: standard, intelligent_tiering, standard_ia, onezone_ia, glacier, glacier_ir, deep_archive, or reduced_redundancy").Action(c.Redundancy.Set).EnumVar(&c.Redundancy.Value, string(fastly.S3RedundancyStandard), string(fastly.S3RedundancyIntelligentTiering), string(fastly.S3RedundancyStandardIA), string(fastly.S3RedundancyOneZoneIA), string(fastly.S3RedundancyGlacierFlexibleRetrieval), string(fastly.S3RedundancyGlacierInstantRetrieval), string(fastly.S3RedundancyGlacierDeepArchive), string(fastly.S3RedundancyReduced)) + common.ResponseCondition(c.CmdClause, &c.ResponseCondition) + c.CmdClause.Flag("secret-key", "Your S3 account secret key").Action(c.SecretKey.Set).StringVar(&c.SecretKey.Value) + c.CmdClause.Flag("server-side-encryption", "Set to enable S3 Server Side Encryption. Can be either AES256 or aws:kms").Action(c.ServerSideEncryption.Set).EnumVar(&c.ServerSideEncryption.Value, string(fastly.S3ServerSideEncryptionAES), string(fastly.S3ServerSideEncryptionKMS)) + c.CmdClause.Flag("server-side-encryption-kms-key-id", "Server-side KMS Key ID. Must be set if server-side-encryption is set to aws:kms").Action(c.ServerSideEncryptionKMSKeyID.Set).StringVar(&c.ServerSideEncryptionKMSKeyID.Value) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.ServiceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.ServiceName.Value, + }) + common.TimestampFormat(c.CmdClause, &c.TimestampFormat) + return &c +} + +// ConstructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *UpdateCommand) ConstructInput(serviceID string, serviceVersion int) (*fastly.UpdateS3Input, error) { + input := fastly.UpdateS3Input{ + ServiceID: serviceID, + ServiceVersion: serviceVersion, + Name: c.EndpointName, + } + + if c.NewName.WasSet { + input.NewName = &c.NewName.Value + } + + if c.BucketName.WasSet { + input.BucketName = &c.BucketName.Value + } + + if c.AccessKey.WasSet { + input.AccessKey = &c.AccessKey.Value + } + + if c.SecretKey.WasSet { + input.SecretKey = &c.SecretKey.Value + } + + if c.IAMRole.WasSet { + input.IAMRole = &c.IAMRole.Value + } + + if c.Domain.WasSet { + input.Domain = &c.Domain.Value + } + + if c.Path.WasSet { + input.Path = &c.Path.Value + } + + if c.Period.WasSet { + input.Period = &c.Period.Value + } + + if c.GzipLevel.WasSet { + input.GzipLevel = &c.GzipLevel.Value + } + + if c.FileMaxBytes.WasSet { + input.FileMaxBytes = &c.FileMaxBytes.Value + } + + if c.Format.WasSet { + input.Format = fastly.ToPointer(argparser.Content(c.Format.Value)) + } + + if c.FormatVersion.WasSet { + input.FormatVersion = &c.FormatVersion.Value + } + + if c.MessageType.WasSet { + input.MessageType = &c.MessageType.Value + } + + if c.ResponseCondition.WasSet { + input.ResponseCondition = &c.ResponseCondition.Value + } + + if c.TimestampFormat.WasSet { + input.TimestampFormat = &c.TimestampFormat.Value + } + + if c.Placement.WasSet { + input.Placement = &c.Placement.Value + } + + if c.PublicKey.WasSet { + input.PublicKey = &c.PublicKey.Value + } + + if c.ServerSideEncryptionKMSKeyID.WasSet { + input.ServerSideEncryptionKMSKeyID = &c.ServerSideEncryptionKMSKeyID.Value + } + + if c.CompressionCodec.WasSet { + input.CompressionCodec = &c.CompressionCodec.Value + } + + if c.Redundancy.WasSet { + redundancy, err := ValidateRedundancy(c.Redundancy.Value) + if err == nil { + input.Redundancy = fastly.ToPointer(redundancy) + } + } + + if c.ServerSideEncryption.WasSet { + switch c.ServerSideEncryption.Value { + case string(fastly.S3ServerSideEncryptionAES): + input.ServerSideEncryption = fastly.ToPointer(fastly.S3ServerSideEncryptionAES) + case string(fastly.S3ServerSideEncryptionKMS): + input.ServerSideEncryption = fastly.ToPointer(fastly.S3ServerSideEncryptionKMS) + } + } + + return &input, nil +} + +// Exec invokes the application logic for the command. +func (c *UpdateCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.AutoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.ServiceName, + ServiceVersionFlag: c.ServiceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + input, err := c.ConstructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + s3, err := c.Globals.APIClient.UpdateS3(input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + text.Success(out, + "Updated S3 logging endpoint %s (service %s version %d)", + fastly.ToValue(s3.Name), + fastly.ToValue(s3.ServiceID), + fastly.ToValue(s3.ServiceVersion), + ) + return nil +} diff --git a/pkg/commands/logging/scalyr/create.go b/pkg/commands/logging/scalyr/create.go new file mode 100644 index 000000000..add891cf0 --- /dev/null +++ b/pkg/commands/logging/scalyr/create.go @@ -0,0 +1,157 @@ +package scalyr + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/logging/common" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/text" +) + +// CreateCommand calls the Fastly API to create a Scalyr logging endpoint. +type CreateCommand struct { + argparser.Base + Manifest manifest.Data + + // Required. + ServiceName argparser.OptionalServiceNameID + EndpointName argparser.OptionalString // Can't shadow argparser.Base method Name(). + Token argparser.OptionalString + ServiceVersion argparser.OptionalServiceVersion + + // Optional. + AutoClone argparser.OptionalAutoClone + Format argparser.OptionalString + FormatVersion argparser.OptionalInt + Placement argparser.OptionalString + Region argparser.OptionalString + ResponseCondition argparser.OptionalString + ProjectID argparser.OptionalString +} + +// NewCreateCommand returns a usable command registered under the parent. +func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { + c := CreateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("create", "Create a Scalyr logging endpoint on a Fastly service version").Alias("add") + + // Required. + c.CmdClause.Flag("name", "The name of the Scalyr logging object. Used as a primary key for API access").Short('n').Action(c.EndpointName.Set).StringVar(&c.EndpointName.Value) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.ServiceVersion.Value, + Required: true, + }) + + // Optional. + c.CmdClause.Flag("auth-token", "The token to use for authentication (https://www.scalyr.com/keys)").Action(c.Token.Set).StringVar(&c.Token.Value) + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.AutoClone.Set, + Dst: &c.AutoClone.Value, + }) + common.Format(c.CmdClause, &c.Format) + common.FormatVersion(c.CmdClause, &c.FormatVersion) + common.Placement(c.CmdClause, &c.Placement) + c.CmdClause.Flag("project-id", "The name of the logfile field sent to Scalyr").Action(c.ProjectID.Set).StringVar(&c.ProjectID.Value) + c.CmdClause.Flag("region", "The region that log data will be sent to. One of US or EU. Defaults to US if undefined").Action(c.Region.Set).StringVar(&c.Region.Value) + common.ResponseCondition(c.CmdClause, &c.ResponseCondition) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.ServiceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.ServiceName.Value, + }) + return &c +} + +// ConstructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *CreateCommand) ConstructInput(serviceID string, serviceVersion int) (*fastly.CreateScalyrInput, error) { + var input fastly.CreateScalyrInput + + input.ServiceID = serviceID + input.ServiceVersion = serviceVersion + if c.EndpointName.WasSet { + input.Name = &c.EndpointName.Value + } + if c.Token.WasSet { + input.Token = &c.Token.Value + } + if c.Region.WasSet { + input.Region = &c.Region.Value + } + if c.Format.WasSet { + input.Format = fastly.ToPointer(argparser.Content(c.Format.Value)) + } + if c.FormatVersion.WasSet { + input.FormatVersion = &c.FormatVersion.Value + } + if c.ResponseCondition.WasSet { + input.ResponseCondition = &c.ResponseCondition.Value + } + if c.Placement.WasSet { + input.Placement = &c.Placement.Value + } + if c.ProjectID.WasSet { + input.ProjectID = &c.ProjectID.Value + } + + return &input, nil +} + +// Exec invokes the application logic for the command. +func (c *CreateCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.AutoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.ServiceName, + ServiceVersionFlag: c.ServiceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + input, err := c.ConstructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + d, err := c.Globals.APIClient.CreateScalyr(input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + text.Success(out, + "Created Scalyr logging endpoint %s (service %s version %d)", + fastly.ToValue(d.Name), + fastly.ToValue(d.ServiceID), + fastly.ToValue(d.ServiceVersion), + ) + return nil +} diff --git a/pkg/commands/logging/scalyr/delete.go b/pkg/commands/logging/scalyr/delete.go new file mode 100644 index 000000000..5ea2f57ac --- /dev/null +++ b/pkg/commands/logging/scalyr/delete.go @@ -0,0 +1,94 @@ +package scalyr + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// DeleteCommand calls the Fastly API to delete a Scalyr logging endpoint. +type DeleteCommand struct { + argparser.Base + Input fastly.DeleteScalyrInput + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion + autoClone argparser.OptionalAutoClone +} + +// NewDeleteCommand returns a usable command registered under the parent. +func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { + c := DeleteCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("delete", "Delete a Scalyr logging endpoint on a Fastly service version").Alias("remove") + + // Required. + c.CmdClause.Flag("name", "The name of the Scalyr logging object").Short('n').Required().StringVar(&c.Input.Name) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.autoClone.Set, + Dst: &c.autoClone.Value, + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + return &c +} + +// Exec invokes the application logic for the command. +func (c *DeleteCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.autoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + c.Input.ServiceID = serviceID + c.Input.ServiceVersion = fastly.ToValue(serviceVersion.Number) + + if err := c.Globals.APIClient.DeleteScalyr(&c.Input); err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + text.Success(out, "Deleted Scalyr logging endpoint %s (service %s version %d)", c.Input.Name, c.Input.ServiceID, c.Input.ServiceVersion) + return nil +} diff --git a/pkg/commands/logging/scalyr/describe.go b/pkg/commands/logging/scalyr/describe.go new file mode 100644 index 000000000..10f85c031 --- /dev/null +++ b/pkg/commands/logging/scalyr/describe.go @@ -0,0 +1,111 @@ +package scalyr + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// DescribeCommand calls the Fastly API to describe a Scalyr logging endpoint. +type DescribeCommand struct { + argparser.Base + argparser.JSONOutput + + Input fastly.GetScalyrInput + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion +} + +// NewDescribeCommand returns a usable command registered under the parent. +func NewDescribeCommand(parent argparser.Registerer, g *global.Data) *DescribeCommand { + c := DescribeCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("describe", "Show detailed information about a Scalyr logging endpoint on a Fastly service version").Alias("get") + + // Required. + c.CmdClause.Flag("name", "The name of the Scalyr logging object").Short('n').Required().StringVar(&c.Input.Name) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + return &c +} + +// Exec invokes the application logic for the command. +func (c *DescribeCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fsterr.ServiceVersion(serviceVersion), + }) + return err + } + + c.Input.ServiceID = serviceID + c.Input.ServiceVersion = fastly.ToValue(serviceVersion.Number) + + o, err := c.Globals.APIClient.GetScalyr(&c.Input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + lines := text.Lines{ + "Format version": fastly.ToValue(o.FormatVersion), + "Format": fastly.ToValue(o.Format), + "Name": fastly.ToValue(o.Name), + "Placement": fastly.ToValue(o.Placement), + "Project ID": fastly.ToValue(o.ProjectID), + "Region": fastly.ToValue(o.Region), + "Response condition": fastly.ToValue(o.ResponseCondition), + "Token": fastly.ToValue(o.Token), + "Version": fastly.ToValue(o.ServiceVersion), + } + if !c.Globals.Verbose() { + lines["Service ID"] = fastly.ToValue(o.ServiceID) + } + text.PrintLines(out, lines) + + return nil +} diff --git a/pkg/logging/scalyr/doc.go b/pkg/commands/logging/scalyr/doc.go similarity index 100% rename from pkg/logging/scalyr/doc.go rename to pkg/commands/logging/scalyr/doc.go diff --git a/pkg/commands/logging/scalyr/list.go b/pkg/commands/logging/scalyr/list.go new file mode 100644 index 000000000..542518b74 --- /dev/null +++ b/pkg/commands/logging/scalyr/list.go @@ -0,0 +1,125 @@ +package scalyr + +import ( + "fmt" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// ListCommand calls the Fastly API to list Scalyr logging endpoints. +type ListCommand struct { + argparser.Base + argparser.JSONOutput + + Input fastly.ListScalyrsInput + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion +} + +// NewListCommand returns a usable command registered under the parent. +func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { + c := ListCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("list", "List Scalyr endpoints on a Fastly service version") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + return &c +} + +// Exec invokes the application logic for the command. +func (c *ListCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fsterr.ServiceVersion(serviceVersion), + }) + return err + } + + c.Input.ServiceID = serviceID + c.Input.ServiceVersion = fastly.ToValue(serviceVersion.Number) + + o, err := c.Globals.APIClient.ListScalyrs(&c.Input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + if !c.Globals.Verbose() { + tw := text.NewTable(out) + tw.AddHeader("SERVICE", "VERSION", "NAME") + for _, scalyr := range o { + tw.AddLine( + fastly.ToValue(scalyr.ServiceID), + fastly.ToValue(scalyr.ServiceVersion), + fastly.ToValue(scalyr.Name), + ) + } + tw.Print() + return nil + } + + fmt.Fprintf(out, "Version: %d\n", c.Input.ServiceVersion) + for i, scalyr := range o { + fmt.Fprintf(out, "\tScalyr %d/%d\n", i+1, len(o)) + fmt.Fprintf(out, "\t\tService ID: %s\n", fastly.ToValue(scalyr.ServiceID)) + fmt.Fprintf(out, "\t\tVersion: %d\n", fastly.ToValue(scalyr.ServiceVersion)) + fmt.Fprintf(out, "\t\tName: %s\n", fastly.ToValue(scalyr.Name)) + fmt.Fprintf(out, "\t\tToken: %s\n", fastly.ToValue(scalyr.Token)) + fmt.Fprintf(out, "\t\tRegion: %s\n", fastly.ToValue(scalyr.Region)) + fmt.Fprintf(out, "\t\tFormat: %s\n", fastly.ToValue(scalyr.Format)) + fmt.Fprintf(out, "\t\tFormat version: %d\n", fastly.ToValue(scalyr.FormatVersion)) + fmt.Fprintf(out, "\t\tResponse condition: %s\n", fastly.ToValue(scalyr.ResponseCondition)) + fmt.Fprintf(out, "\t\tPlacement: %s\n", fastly.ToValue(scalyr.Placement)) + fmt.Fprintf(out, "\t\tProject ID: %s\n", fastly.ToValue(scalyr.ProjectID)) + } + fmt.Fprintln(out) + + return nil +} diff --git a/pkg/commands/logging/scalyr/root.go b/pkg/commands/logging/scalyr/root.go new file mode 100644 index 000000000..7ae0b2142 --- /dev/null +++ b/pkg/commands/logging/scalyr/root.go @@ -0,0 +1,31 @@ +package scalyr + +import ( + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" +) + +// RootCommand is the parent command for all subcommands in this package. +// It should be installed under the primary root command. +type RootCommand struct { + argparser.Base + // no flags +} + +// CommandName is the string to be used to invoke this command. +const CommandName = "scalyr" + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent argparser.Registerer, g *global.Data) *RootCommand { + var c RootCommand + c.Globals = g + c.CmdClause = parent.Command(CommandName, "Manipulate Fastly service version Scalyr logging endpoints") + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { + panic("unreachable") +} diff --git a/pkg/commands/logging/scalyr/scalyr_integration_test.go b/pkg/commands/logging/scalyr/scalyr_integration_test.go new file mode 100644 index 000000000..be1752106 --- /dev/null +++ b/pkg/commands/logging/scalyr/scalyr_integration_test.go @@ -0,0 +1,449 @@ +package scalyr_test + +import ( + "bytes" + "errors" + "io" + "strings" + "testing" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/app" + fsterrs "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" +) + +func TestScalyrCreate(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging scalyr create --name log --service-id --version 1 --auth-token abc --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + }, + wantError: fsterrs.ErrNoServiceID.Error(), + }, + { + args: args("logging scalyr create --service-id 123 --version 1 --name log --auth-token abc --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + CreateScalyrFn: createScalyrOK, + }, + wantOutput: "Created Scalyr logging endpoint log (service 123 version 4)", + }, + { + args: args("logging scalyr create --service-id 123 --version 1 --name log --auth-token abc --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + CreateScalyrFn: createScalyrError, + }, + wantError: errTest.Error(), + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertStringContains(t, stdout.String(), testcase.wantOutput) + }) + } +} + +func TestScalyrList(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging scalyr list --service-id 123 --version 1"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListScalyrsFn: listScalyrsOK, + }, + wantOutput: listScalyrsShortOutput, + }, + { + args: args("logging scalyr list --service-id 123 --version 1 --verbose"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListScalyrsFn: listScalyrsOK, + }, + wantOutput: listScalyrsVerboseOutput, + }, + { + args: args("logging scalyr list --service-id 123 --version 1 -v"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListScalyrsFn: listScalyrsOK, + }, + wantOutput: listScalyrsVerboseOutput, + }, + { + args: args("logging scalyr --verbose list --service-id 123 --version 1"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListScalyrsFn: listScalyrsOK, + }, + wantOutput: listScalyrsVerboseOutput, + }, + { + args: args("logging -v scalyr list --service-id 123 --version 1"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListScalyrsFn: listScalyrsOK, + }, + wantOutput: listScalyrsVerboseOutput, + }, + { + args: args("logging scalyr list --service-id 123 --version 1"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListScalyrsFn: listScalyrsError, + }, + wantError: errTest.Error(), + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertString(t, testcase.wantOutput, stdout.String()) + }) + } +} + +func TestScalyrDescribe(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging scalyr describe --service-id 123 --version 1"), + wantError: "error parsing arguments: required flag --name not provided", + }, + { + args: args("logging scalyr describe --service-id 123 --version 1 --name logs"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + GetScalyrFn: getScalyrError, + }, + wantError: errTest.Error(), + }, + { + args: args("logging scalyr describe --service-id 123 --version 1 --name logs"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + GetScalyrFn: getScalyrOK, + }, + wantOutput: describeScalyrOutput, + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertString(t, testcase.wantOutput, stdout.String()) + }) + } +} + +func TestScalyrUpdate(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging scalyr update --service-id 123 --version 1 --new-name log"), + wantError: "error parsing arguments: required flag --name not provided", + }, + { + args: args("logging scalyr update --service-id 123 --version 1 --name logs --new-name log --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + UpdateScalyrFn: updateScalyrError, + }, + wantError: errTest.Error(), + }, + { + args: args("logging scalyr update --service-id 123 --version 1 --name logs --new-name log --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + UpdateScalyrFn: updateScalyrOK, + }, + wantOutput: "Updated Scalyr logging endpoint log (service 123 version 4)", + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertStringContains(t, stdout.String(), testcase.wantOutput) + }) + } +} + +func TestScalyrDelete(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging scalyr delete --service-id 123 --version 1"), + wantError: "error parsing arguments: required flag --name not provided", + }, + { + args: args("logging scalyr delete --service-id 123 --version 1 --name logs --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + DeleteScalyrFn: deleteScalyrError, + }, + wantError: errTest.Error(), + }, + { + args: args("logging scalyr delete --service-id 123 --version 1 --name logs --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + DeleteScalyrFn: deleteScalyrOK, + }, + wantOutput: "Deleted Scalyr logging endpoint logs (service 123 version 4)", + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertStringContains(t, stdout.String(), testcase.wantOutput) + }) + } +} + +var errTest = errors.New("fixture error") + +func createScalyrOK(i *fastly.CreateScalyrInput) (*fastly.Scalyr, error) { + s := fastly.Scalyr{ + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + } + + // Avoids null pointer dereference for test cases with missing required params. + // If omitted, tests are guaranteed to panic. + if i.Name != nil { + s.Name = i.Name + } + + if i.Token != nil { + s.Token = i.Token + } + + if i.Format != nil { + s.Format = i.Format + } + + if i.FormatVersion != nil { + s.FormatVersion = i.FormatVersion + } + + if i.ResponseCondition != nil { + s.ResponseCondition = i.ResponseCondition + } + + if i.Placement != nil { + s.Placement = i.Placement + } + + return &s, nil +} + +func createScalyrError(_ *fastly.CreateScalyrInput) (*fastly.Scalyr, error) { + return nil, errTest +} + +func listScalyrsOK(i *fastly.ListScalyrsInput) ([]*fastly.Scalyr, error) { + return []*fastly.Scalyr{ + { + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("logs"), + Token: fastly.ToPointer("abc"), + Region: fastly.ToPointer("US"), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + FormatVersion: fastly.ToPointer(2), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + Placement: fastly.ToPointer("none"), + ProjectID: fastly.ToPointer("example-project"), + }, + { + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("analytics"), + Token: fastly.ToPointer("abc"), + Region: fastly.ToPointer("US"), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + FormatVersion: fastly.ToPointer(2), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + Placement: fastly.ToPointer("none"), + ProjectID: fastly.ToPointer("example-project"), + }, + }, nil +} + +func listScalyrsError(_ *fastly.ListScalyrsInput) ([]*fastly.Scalyr, error) { + return nil, errTest +} + +var listScalyrsShortOutput = strings.TrimSpace(` +SERVICE VERSION NAME +123 1 logs +123 1 analytics +`) + "\n" + +var listScalyrsVerboseOutput = strings.TrimSpace(` +Fastly API endpoint: https://api.fastly.com +Fastly API token provided via config file (profile: user) + +Service ID (via --service-id): 123 + +Version: 1 + Scalyr 1/2 + Service ID: 123 + Version: 1 + Name: logs + Token: abc + Region: US + Format: %h %l %u %t "%r" %>s %b + Format version: 2 + Response condition: Prevent default logging + Placement: none + Project ID: example-project + Scalyr 2/2 + Service ID: 123 + Version: 1 + Name: analytics + Token: abc + Region: US + Format: %h %l %u %t "%r" %>s %b + Format version: 2 + Response condition: Prevent default logging + Placement: none + Project ID: example-project +`) + "\n\n" + +func getScalyrOK(i *fastly.GetScalyrInput) (*fastly.Scalyr, error) { + return &fastly.Scalyr{ + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("logs"), + Token: fastly.ToPointer("abc"), + Region: fastly.ToPointer("US"), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + FormatVersion: fastly.ToPointer(2), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + Placement: fastly.ToPointer("none"), + ProjectID: fastly.ToPointer("example-project"), + }, nil +} + +func getScalyrError(_ *fastly.GetScalyrInput) (*fastly.Scalyr, error) { + return nil, errTest +} + +var describeScalyrOutput = "\n" + strings.TrimSpace(` +Format: %h %l %u %t "%r" %>s %b +Format version: 2 +Name: logs +Placement: none +Project ID: example-project +Region: US +Response condition: Prevent default logging +Service ID: 123 +Token: abc +Version: 1 +`) + "\n" + +func updateScalyrOK(i *fastly.UpdateScalyrInput) (*fastly.Scalyr, error) { + return &fastly.Scalyr{ + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("log"), + Token: fastly.ToPointer("abc"), + Region: fastly.ToPointer("EU"), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + FormatVersion: fastly.ToPointer(2), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + Placement: fastly.ToPointer("none"), + }, nil +} + +func updateScalyrError(_ *fastly.UpdateScalyrInput) (*fastly.Scalyr, error) { + return nil, errTest +} + +func deleteScalyrOK(_ *fastly.DeleteScalyrInput) error { + return nil +} + +func deleteScalyrError(_ *fastly.DeleteScalyrInput) error { + return errTest +} diff --git a/pkg/commands/logging/scalyr/scalyr_test.go b/pkg/commands/logging/scalyr/scalyr_test.go new file mode 100644 index 000000000..643f4dc8c --- /dev/null +++ b/pkg/commands/logging/scalyr/scalyr_test.go @@ -0,0 +1,344 @@ +package scalyr_test + +import ( + "bytes" + "testing" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/logging/scalyr" + "github.com/fastly/cli/pkg/config" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" +) + +func TestCreateScalyrInput(t *testing.T) { + for _, testcase := range []struct { + name string + cmd *scalyr.CreateCommand + want *fastly.CreateScalyrInput + wantError string + }{ + { + name: "required values set flag serviceID", + cmd: createCommandRequired(), + want: &fastly.CreateScalyrInput{ + ServiceID: "123", + ServiceVersion: 4, + Name: fastly.ToPointer("log"), + Token: fastly.ToPointer("tkn"), + }, + }, + { + name: "all values set flag serviceID", + cmd: createCommandAll(), + want: &fastly.CreateScalyrInput{ + ServiceID: "123", + ServiceVersion: 4, + Name: fastly.ToPointer("log"), + Token: fastly.ToPointer("tkn"), + Region: fastly.ToPointer("US"), + FormatVersion: fastly.ToPointer(2), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + Placement: fastly.ToPointer("none"), + ProjectID: fastly.ToPointer("example-project"), + }, + }, + { + name: "error missing serviceID", + cmd: createCommandMissingServiceID(), + want: nil, + wantError: errors.ErrNoServiceID.Error(), + }, + } { + t.Run(testcase.name, func(t *testing.T) { + var bs []byte + out := bytes.NewBuffer(bs) + verboseMode := true + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + AutoCloneFlag: testcase.cmd.AutoClone, + APIClient: testcase.cmd.Globals.APIClient, + Manifest: testcase.cmd.Manifest, + Out: out, + ServiceVersionFlag: testcase.cmd.ServiceVersion, + VerboseMode: verboseMode, + }) + + switch { + case err != nil && testcase.wantError == "": + t.Fatalf("unexpected error getting service details: %v", err) + return + case err != nil && testcase.wantError != "": + testutil.AssertErrorContains(t, err, testcase.wantError) + return + case err == nil && testcase.wantError != "": + t.Fatalf("expected error, have nil (service details: %s, %d)", serviceID, serviceVersion.Number) + case err == nil && testcase.wantError == "": + have, err := testcase.cmd.ConstructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertEqual(t, testcase.want, have) + } + }) + } +} + +func TestUpdateScalyrInput(t *testing.T) { + scenarios := []struct { + name string + cmd *scalyr.UpdateCommand + api mock.API + want *fastly.UpdateScalyrInput + wantError string + }{ + { + name: "no updates", + cmd: updateCommandNoUpdates(), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + GetScalyrFn: getScalyrOK, + }, + want: &fastly.UpdateScalyrInput{ + ServiceID: "123", + ServiceVersion: 4, + Name: "log", + }, + }, + { + name: "all values set flag serviceID", + cmd: updateCommandAll(), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + GetScalyrFn: getScalyrOK, + }, + want: &fastly.UpdateScalyrInput{ + ServiceID: "123", + ServiceVersion: 4, + Name: "log", + NewName: fastly.ToPointer("new1"), + Token: fastly.ToPointer("new2"), + FormatVersion: fastly.ToPointer(3), + Format: fastly.ToPointer("new3"), + ResponseCondition: fastly.ToPointer("new4"), + Placement: fastly.ToPointer("new5"), + Region: fastly.ToPointer("new6"), + ProjectID: fastly.ToPointer("new7"), + }, + }, + { + name: "error missing serviceID", + cmd: updateCommandMissingServiceID(), + want: nil, + wantError: errors.ErrNoServiceID.Error(), + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(testcase.name, func(t *testing.T) { + testcase.cmd.Globals.APIClient = testcase.api + + var bs []byte + out := bytes.NewBuffer(bs) + verboseMode := true + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + AutoCloneFlag: testcase.cmd.AutoClone, + APIClient: testcase.api, + Manifest: testcase.cmd.Manifest, + Out: out, + ServiceVersionFlag: testcase.cmd.ServiceVersion, + VerboseMode: verboseMode, + }) + + switch { + case err != nil && testcase.wantError == "": + t.Fatalf("unexpected error getting service details: %v", err) + return + case err != nil && testcase.wantError != "": + testutil.AssertErrorContains(t, err, testcase.wantError) + return + case err == nil && testcase.wantError != "": + t.Fatalf("expected error, have nil (service details: %s, %d)", serviceID, serviceVersion.Number) + case err == nil && testcase.wantError == "": + have, err := testcase.cmd.ConstructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertEqual(t, testcase.want, have) + } + }) + } +} + +func createCommandRequired() *scalyr.CreateCommand { + var b bytes.Buffer + + g := global.Data{ + Config: config.File{}, + Env: config.Environment{}, + Output: &b, + } + g.APIClient, _ = mock.APIClient(mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + })("token", "endpoint", false) + + return &scalyr.CreateCommand{ + Base: argparser.Base{ + Globals: &g, + }, + Manifest: manifest.Data{ + Flag: manifest.Flag{ + ServiceID: "123", + }, + }, + ServiceVersion: argparser.OptionalServiceVersion{ + OptionalString: argparser.OptionalString{Value: "1"}, + }, + AutoClone: argparser.OptionalAutoClone{ + OptionalBool: argparser.OptionalBool{ + Optional: argparser.Optional{ + WasSet: true, + }, + Value: true, + }, + }, + EndpointName: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "log"}, + Token: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "tkn"}, + } +} + +func createCommandAll() *scalyr.CreateCommand { + var b bytes.Buffer + + g := global.Data{ + Config: config.File{}, + Env: config.Environment{}, + Output: &b, + } + g.APIClient, _ = mock.APIClient(mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + })("token", "endpoint", false) + + return &scalyr.CreateCommand{ + Base: argparser.Base{ + Globals: &g, + }, + Manifest: manifest.Data{ + Flag: manifest.Flag{ + ServiceID: "123", + }, + }, + ServiceVersion: argparser.OptionalServiceVersion{ + OptionalString: argparser.OptionalString{Value: "1"}, + }, + AutoClone: argparser.OptionalAutoClone{ + OptionalBool: argparser.OptionalBool{ + Optional: argparser.Optional{ + WasSet: true, + }, + Value: true, + }, + }, + EndpointName: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "log"}, + Token: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "tkn"}, + Region: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "US"}, + Format: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: `%h %l %u %t "%r" %>s %b`}, + FormatVersion: argparser.OptionalInt{Optional: argparser.Optional{WasSet: true}, Value: 2}, + ResponseCondition: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "Prevent default logging"}, + Placement: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "none"}, + ProjectID: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "example-project"}, + } +} + +func createCommandMissingServiceID() *scalyr.CreateCommand { + res := createCommandAll() + res.Manifest = manifest.Data{} + return res +} + +func updateCommandNoUpdates() *scalyr.UpdateCommand { + var b bytes.Buffer + + g := global.Data{ + Config: config.File{}, + Env: config.Environment{}, + Output: &b, + } + + return &scalyr.UpdateCommand{ + Base: argparser.Base{ + Globals: &g, + }, + Manifest: manifest.Data{ + Flag: manifest.Flag{ + ServiceID: "123", + }, + }, + EndpointName: "log", + ServiceVersion: argparser.OptionalServiceVersion{ + OptionalString: argparser.OptionalString{Value: "1"}, + }, + AutoClone: argparser.OptionalAutoClone{ + OptionalBool: argparser.OptionalBool{ + Optional: argparser.Optional{ + WasSet: true, + }, + Value: true, + }, + }, + } +} + +func updateCommandAll() *scalyr.UpdateCommand { + var b bytes.Buffer + + g := global.Data{ + Config: config.File{}, + Env: config.Environment{}, + Output: &b, + } + + return &scalyr.UpdateCommand{ + Base: argparser.Base{ + Globals: &g, + }, + Manifest: manifest.Data{ + Flag: manifest.Flag{ + ServiceID: "123", + }, + }, + EndpointName: "log", + ServiceVersion: argparser.OptionalServiceVersion{ + OptionalString: argparser.OptionalString{Value: "1"}, + }, + AutoClone: argparser.OptionalAutoClone{ + OptionalBool: argparser.OptionalBool{ + Optional: argparser.Optional{ + WasSet: true, + }, + Value: true, + }, + }, + NewName: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new1"}, + Token: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new2"}, + Format: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new3"}, + FormatVersion: argparser.OptionalInt{Optional: argparser.Optional{WasSet: true}, Value: 3}, + ResponseCondition: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new4"}, + Placement: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new5"}, + Region: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new6"}, + ProjectID: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new7"}, + } +} + +func updateCommandMissingServiceID() *scalyr.UpdateCommand { + res := updateCommandAll() + res.Manifest = manifest.Data{} + return res +} diff --git a/pkg/commands/logging/scalyr/update.go b/pkg/commands/logging/scalyr/update.go new file mode 100644 index 000000000..c819d3f32 --- /dev/null +++ b/pkg/commands/logging/scalyr/update.go @@ -0,0 +1,160 @@ +package scalyr + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/logging/common" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/text" +) + +// UpdateCommand calls the Fastly API to update Scalyr logging endpoints. +type UpdateCommand struct { + argparser.Base + Manifest manifest.Data + + // Required. + EndpointName string // Can't shadow argparser.Base method Name(). + ServiceName argparser.OptionalServiceNameID + ServiceVersion argparser.OptionalServiceVersion + + // Optional. + AutoClone argparser.OptionalAutoClone + NewName argparser.OptionalString + Format argparser.OptionalString + FormatVersion argparser.OptionalInt + Token argparser.OptionalString + Region argparser.OptionalString + ResponseCondition argparser.OptionalString + Placement argparser.OptionalString + ProjectID argparser.OptionalString +} + +// NewUpdateCommand returns a usable command registered under the parent. +func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { + c := UpdateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("update", "Update a Scalyr logging endpoint on a Fastly service version") + + // Required. + c.CmdClause.Flag("name", "The name of the Scalyr logging object").Short('n').Required().StringVar(&c.EndpointName) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.ServiceVersion.Value, + Required: true, + }) + + // Optional. + c.CmdClause.Flag("auth-token", "The token to use for authentication (https://www.scalyr.com/keys)").Action(c.Token.Set).StringVar(&c.Token.Value) + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.AutoClone.Set, + Dst: &c.AutoClone.Value, + }) + common.Format(c.CmdClause, &c.Format) + common.FormatVersion(c.CmdClause, &c.FormatVersion) + c.CmdClause.Flag("new-name", "New name of the Scalyr logging object").Action(c.NewName.Set).StringVar(&c.NewName.Value) + common.Placement(c.CmdClause, &c.Placement) + c.CmdClause.Flag("project-id", "The name of the logfile field sent to Scalyr").Action(c.ProjectID.Set).StringVar(&c.ProjectID.Value) + c.CmdClause.Flag("region", "The region that log data will be sent to. One of US or EU. Defaults to US if undefined").Action(c.Region.Set).StringVar(&c.Region.Value) + common.ResponseCondition(c.CmdClause, &c.ResponseCondition) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.ServiceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.ServiceName.Value, + }) + return &c +} + +// ConstructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *UpdateCommand) ConstructInput(serviceID string, serviceVersion int) (*fastly.UpdateScalyrInput, error) { + input := fastly.UpdateScalyrInput{ + ServiceID: serviceID, + ServiceVersion: serviceVersion, + Name: c.EndpointName, + } + if c.NewName.WasSet { + input.NewName = &c.NewName.Value + } + if c.Format.WasSet { + input.Format = fastly.ToPointer(argparser.Content(c.Format.Value)) + } + if c.FormatVersion.WasSet { + input.FormatVersion = &c.FormatVersion.Value + } + if c.Token.WasSet { + input.Token = &c.Token.Value + } + if c.Region.WasSet { + input.Region = &c.Region.Value + } + if c.ResponseCondition.WasSet { + input.ResponseCondition = &c.ResponseCondition.Value + } + if c.Placement.WasSet { + input.Placement = &c.Placement.Value + } + if c.ProjectID.WasSet { + input.ProjectID = &c.ProjectID.Value + } + return &input, nil +} + +// Exec invokes the application logic for the command. +func (c *UpdateCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.AutoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.ServiceName, + ServiceVersionFlag: c.ServiceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + input, err := c.ConstructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + scalyr, err := c.Globals.APIClient.UpdateScalyr(input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + text.Success(out, + "Updated Scalyr logging endpoint %s (service %s version %d)", + fastly.ToValue(scalyr.Name), + fastly.ToValue(scalyr.ServiceID), + fastly.ToValue(scalyr.ServiceVersion), + ) + return nil +} diff --git a/pkg/commands/logging/sftp/create.go b/pkg/commands/logging/sftp/create.go new file mode 100644 index 000000000..77d37b109 --- /dev/null +++ b/pkg/commands/logging/sftp/create.go @@ -0,0 +1,229 @@ +package sftp + +import ( + "fmt" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/logging/common" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/text" +) + +// CreateCommand calls the Fastly API to create an SFTP logging endpoint. +type CreateCommand struct { + argparser.Base + Manifest manifest.Data + + // Required. + ServiceName argparser.OptionalServiceNameID + ServiceVersion argparser.OptionalServiceVersion + + // Optional. + Address argparser.OptionalString + AutoClone argparser.OptionalAutoClone + CompressionCodec argparser.OptionalString + EndpointName argparser.OptionalString // Can't shadow argparser.Base method Name(). + Format argparser.OptionalString + FormatVersion argparser.OptionalInt + GzipLevel argparser.OptionalInt + MessageType argparser.OptionalString + Password argparser.OptionalString + Path argparser.OptionalString + Period argparser.OptionalInt + Placement argparser.OptionalString + Port argparser.OptionalInt + PublicKey argparser.OptionalString + ResponseCondition argparser.OptionalString + SecretKey argparser.OptionalString + SSHKnownHosts argparser.OptionalString + TimestampFormat argparser.OptionalString + User argparser.OptionalString +} + +// NewCreateCommand returns a usable command registered under the parent. +func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { + c := CreateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("create", "Create an SFTP logging endpoint on a Fastly service version").Alias("add") + + // Required. + c.CmdClause.Flag("name", "The name of the SFTP logging object. Used as a primary key for API access").Short('n').Action(c.EndpointName.Set).StringVar(&c.EndpointName.Value) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.ServiceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.AutoClone.Set, + Dst: &c.AutoClone.Value, + }) + c.CmdClause.Flag("address", "The hostname or IPv4 address").Action(c.Address.Set).StringVar(&c.Address.Value) + common.CompressionCodec(c.CmdClause, &c.CompressionCodec) + common.Format(c.CmdClause, &c.Format) + common.FormatVersion(c.CmdClause, &c.FormatVersion) + common.GzipLevel(c.CmdClause, &c.GzipLevel) + common.MessageType(c.CmdClause, &c.MessageType) + c.CmdClause.Flag("password", "The password for the server. If both password and secret_key are passed, secret_key will be used in preference").Action(c.Password.Set).StringVar(&c.Password.Value) + c.CmdClause.Flag("path", "The path to upload logs to. The directory must exist on the SFTP server before logs can be saved to it").Action(c.Path.Set).StringVar(&c.Path.Value) + common.Period(c.CmdClause, &c.Period) + common.Placement(c.CmdClause, &c.Placement) + c.CmdClause.Flag("port", "The port number").Action(c.Port.Set).IntVar(&c.Port.Value) + common.PublicKey(c.CmdClause, &c.PublicKey) + common.ResponseCondition(c.CmdClause, &c.ResponseCondition) + c.CmdClause.Flag("secret-key", "The SSH private key for the server. If both password and secret_key are passed, secret_key will be used in preference").Action(c.SecretKey.Set).StringVar(&c.SecretKey.Value) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.ServiceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.ServiceName.Value, + }) + c.CmdClause.Flag("ssh-known-hosts", "A list of host keys for all hosts we can connect to over SFTP").Action(c.SSHKnownHosts.Set).StringVar(&c.SSHKnownHosts.Value) + common.TimestampFormat(c.CmdClause, &c.TimestampFormat) + c.CmdClause.Flag("user", "The username for the server").Action(c.User.Set).StringVar(&c.User.Value) + return &c +} + +// ConstructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *CreateCommand) ConstructInput(serviceID string, serviceVersion int) (*fastly.CreateSFTPInput, error) { + var input fastly.CreateSFTPInput + + input.ServiceID = serviceID + input.ServiceVersion = serviceVersion + if c.EndpointName.WasSet { + input.Name = &c.EndpointName.Value + } + if c.Address.WasSet { + input.Address = &c.Address.Value + } + if c.User.WasSet { + input.User = &c.User.Value + } + if c.SSHKnownHosts.WasSet { + input.SSHKnownHosts = &c.SSHKnownHosts.Value + } + + // The following blocks enforces the mutual exclusivity of the + // CompressionCodec and GzipLevel flags. + if c.CompressionCodec.WasSet && c.GzipLevel.WasSet { + return nil, fmt.Errorf("error parsing arguments: the --compression-codec flag is mutually exclusive with the --gzip-level flag") + } + + if c.Port.WasSet { + input.Port = &c.Port.Value + } + + if c.Password.WasSet { + input.Password = &c.Password.Value + } + + if c.PublicKey.WasSet { + input.PublicKey = &c.PublicKey.Value + } + + if c.SecretKey.WasSet { + input.SecretKey = &c.SecretKey.Value + } + + if c.Path.WasSet { + input.Path = &c.Path.Value + } + + if c.Period.WasSet { + input.Period = &c.Period.Value + } + + if c.Format.WasSet { + input.Format = fastly.ToPointer(argparser.Content(c.Format.Value)) + } + + if c.FormatVersion.WasSet { + input.FormatVersion = &c.FormatVersion.Value + } + + if c.GzipLevel.WasSet { + input.GzipLevel = &c.GzipLevel.Value + } + + if c.MessageType.WasSet { + input.MessageType = &c.MessageType.Value + } + + if c.ResponseCondition.WasSet { + input.ResponseCondition = &c.ResponseCondition.Value + } + + if c.TimestampFormat.WasSet { + input.TimestampFormat = &c.TimestampFormat.Value + } + + if c.Placement.WasSet { + input.Placement = &c.Placement.Value + } + + if c.CompressionCodec.WasSet { + input.CompressionCodec = &c.CompressionCodec.Value + } + + return &input, nil +} + +// Exec invokes the application logic for the command. +func (c *CreateCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.AutoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.ServiceName, + ServiceVersionFlag: c.ServiceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + input, err := c.ConstructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + d, err := c.Globals.APIClient.CreateSFTP(input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + text.Success(out, + "Created SFTP logging endpoint %s (service %s version %d)", + fastly.ToValue(d.Name), + fastly.ToValue(d.ServiceID), + fastly.ToValue(d.ServiceVersion), + ) + return nil +} diff --git a/pkg/commands/logging/sftp/delete.go b/pkg/commands/logging/sftp/delete.go new file mode 100644 index 000000000..d8ca2f8b2 --- /dev/null +++ b/pkg/commands/logging/sftp/delete.go @@ -0,0 +1,94 @@ +package sftp + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// DeleteCommand calls the Fastly API to delete an SFTP logging endpoint. +type DeleteCommand struct { + argparser.Base + Input fastly.DeleteSFTPInput + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion + autoClone argparser.OptionalAutoClone +} + +// NewDeleteCommand returns a usable command registered under the parent. +func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { + c := DeleteCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("delete", "Delete an SFTP logging endpoint on a Fastly service version").Alias("remove") + + // Required. + c.CmdClause.Flag("name", "The name of the SFTP logging object").Short('n').Required().StringVar(&c.Input.Name) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.autoClone.Set, + Dst: &c.autoClone.Value, + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + return &c +} + +// Exec invokes the application logic for the command. +func (c *DeleteCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.autoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + c.Input.ServiceID = serviceID + c.Input.ServiceVersion = fastly.ToValue(serviceVersion.Number) + + if err := c.Globals.APIClient.DeleteSFTP(&c.Input); err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + text.Success(out, "Deleted SFTP logging endpoint %s (service %s version %d)", c.Input.Name, c.Input.ServiceID, c.Input.ServiceVersion) + return nil +} diff --git a/pkg/commands/logging/sftp/describe.go b/pkg/commands/logging/sftp/describe.go new file mode 100644 index 000000000..dae6fe3fa --- /dev/null +++ b/pkg/commands/logging/sftp/describe.go @@ -0,0 +1,121 @@ +package sftp + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// DescribeCommand calls the Fastly API to describe an SFTP logging endpoint. +type DescribeCommand struct { + argparser.Base + argparser.JSONOutput + + Input fastly.GetSFTPInput + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion +} + +// NewDescribeCommand returns a usable command registered under the parent. +func NewDescribeCommand(parent argparser.Registerer, g *global.Data) *DescribeCommand { + c := DescribeCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("describe", "Show detailed information about an SFTP logging endpoint on a Fastly service version").Alias("get") + + // Required. + c.CmdClause.Flag("name", "The name of the SFTP logging object").Short('n').Required().StringVar(&c.Input.Name) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + return &c +} + +// Exec invokes the application logic for the command. +func (c *DescribeCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fsterr.ServiceVersion(serviceVersion), + }) + return err + } + + c.Input.ServiceID = serviceID + c.Input.ServiceVersion = fastly.ToValue(serviceVersion.Number) + + o, err := c.Globals.APIClient.GetSFTP(&c.Input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + lines := text.Lines{ + "Address": fastly.ToValue(o.Address), + "Compression codec": fastly.ToValue(o.CompressionCodec), + "Format version": fastly.ToValue(o.FormatVersion), + "Format": fastly.ToValue(o.Format), + "GZip level": fastly.ToValue(o.GzipLevel), + "Message type": fastly.ToValue(o.MessageType), + "Name": fastly.ToValue(o.Name), + "Password": fastly.ToValue(o.Password), + "Path": fastly.ToValue(o.Path), + "Period": fastly.ToValue(o.Period), + "Placement": fastly.ToValue(o.Placement), + "Port": fastly.ToValue(o.Port), + "Public key": fastly.ToValue(o.PublicKey), + "Response condition": fastly.ToValue(o.ResponseCondition), + "Secret key": fastly.ToValue(o.SecretKey), + "SSH known hosts": fastly.ToValue(o.SSHKnownHosts), + "Timestamp format": fastly.ToValue(o.TimestampFormat), + "User": fastly.ToValue(o.User), + "Version": fastly.ToValue(o.ServiceVersion), + } + if !c.Globals.Verbose() { + lines["Service ID"] = fastly.ToValue(o.ServiceID) + } + text.PrintLines(out, lines) + + return nil +} diff --git a/pkg/logging/sftp/doc.go b/pkg/commands/logging/sftp/doc.go similarity index 100% rename from pkg/logging/sftp/doc.go rename to pkg/commands/logging/sftp/doc.go diff --git a/pkg/commands/logging/sftp/list.go b/pkg/commands/logging/sftp/list.go new file mode 100644 index 000000000..761a13924 --- /dev/null +++ b/pkg/commands/logging/sftp/list.go @@ -0,0 +1,135 @@ +package sftp + +import ( + "fmt" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// ListCommand calls the Fastly API to list SFTP logging endpoints. +type ListCommand struct { + argparser.Base + argparser.JSONOutput + + Input fastly.ListSFTPsInput + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion +} + +// NewListCommand returns a usable command registered under the parent. +func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { + c := ListCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("list", "List SFTP endpoints on a Fastly service version") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + return &c +} + +// Exec invokes the application logic for the command. +func (c *ListCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fsterr.ServiceVersion(serviceVersion), + }) + return err + } + + c.Input.ServiceID = serviceID + c.Input.ServiceVersion = fastly.ToValue(serviceVersion.Number) + + o, err := c.Globals.APIClient.ListSFTPs(&c.Input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + if !c.Globals.Verbose() { + tw := text.NewTable(out) + tw.AddHeader("SERVICE", "VERSION", "NAME") + for _, sftp := range o { + tw.AddLine( + fastly.ToValue(sftp.ServiceID), + fastly.ToValue(sftp.ServiceVersion), + fastly.ToValue(sftp.Name), + ) + } + tw.Print() + return nil + } + + fmt.Fprintf(out, "Version: %d\n", c.Input.ServiceVersion) + for i, sftp := range o { + fmt.Fprintf(out, "\tSFTP %d/%d\n", i+1, len(o)) + fmt.Fprintf(out, "\t\tService ID: %s\n", fastly.ToValue(sftp.ServiceID)) + fmt.Fprintf(out, "\t\tVersion: %d\n", fastly.ToValue(sftp.ServiceVersion)) + fmt.Fprintf(out, "\t\tName: %s\n", fastly.ToValue(sftp.Name)) + fmt.Fprintf(out, "\t\tAddress: %s\n", fastly.ToValue(sftp.Address)) + fmt.Fprintf(out, "\t\tPort: %d\n", fastly.ToValue(sftp.Port)) + fmt.Fprintf(out, "\t\tUser: %s\n", fastly.ToValue(sftp.User)) + fmt.Fprintf(out, "\t\tPassword: %s\n", fastly.ToValue(sftp.Password)) + fmt.Fprintf(out, "\t\tPublic key: %s\n", fastly.ToValue(sftp.PublicKey)) + fmt.Fprintf(out, "\t\tSecret key: %s\n", fastly.ToValue(sftp.SecretKey)) + fmt.Fprintf(out, "\t\tSSH known hosts: %s\n", fastly.ToValue(sftp.SSHKnownHosts)) + fmt.Fprintf(out, "\t\tPath: %s\n", fastly.ToValue(sftp.Path)) + fmt.Fprintf(out, "\t\tPeriod: %d\n", fastly.ToValue(sftp.Period)) + fmt.Fprintf(out, "\t\tGZip level: %d\n", fastly.ToValue(sftp.GzipLevel)) + fmt.Fprintf(out, "\t\tFormat: %s\n", fastly.ToValue(sftp.Format)) + fmt.Fprintf(out, "\t\tFormat version: %d\n", fastly.ToValue(sftp.FormatVersion)) + fmt.Fprintf(out, "\t\tMessage type: %s\n", fastly.ToValue(sftp.MessageType)) + fmt.Fprintf(out, "\t\tResponse condition: %s\n", fastly.ToValue(sftp.ResponseCondition)) + fmt.Fprintf(out, "\t\tTimestamp format: %s\n", fastly.ToValue(sftp.TimestampFormat)) + fmt.Fprintf(out, "\t\tPlacement: %s\n", fastly.ToValue(sftp.Placement)) + fmt.Fprintf(out, "\t\tCompression codec: %s\n", fastly.ToValue(sftp.CompressionCodec)) + } + fmt.Fprintln(out) + + return nil +} diff --git a/pkg/commands/logging/sftp/root.go b/pkg/commands/logging/sftp/root.go new file mode 100644 index 000000000..42c58f536 --- /dev/null +++ b/pkg/commands/logging/sftp/root.go @@ -0,0 +1,31 @@ +package sftp + +import ( + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" +) + +// RootCommand is the parent command for all subcommands in this package. +// It should be installed under the primary root command. +type RootCommand struct { + argparser.Base + // no flags +} + +// CommandName is the string to be used to invoke this command. +const CommandName = "sftp" + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent argparser.Registerer, g *global.Data) *RootCommand { + var c RootCommand + c.Globals = g + c.CmdClause = parent.Command(CommandName, "Manipulate Fastly service version SFTP logging endpoints") + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { + panic("unreachable") +} diff --git a/pkg/commands/logging/sftp/sftp_integration_test.go b/pkg/commands/logging/sftp/sftp_integration_test.go new file mode 100644 index 000000000..e1a98e59a --- /dev/null +++ b/pkg/commands/logging/sftp/sftp_integration_test.go @@ -0,0 +1,556 @@ +package sftp_test + +import ( + "bytes" + "errors" + "io" + "strings" + "testing" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/app" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" +) + +func TestSFTPCreate(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging sftp create --service-id 123 --version 1 --name log --address example.com --user user --ssh-known-hosts knownHosts() --port 80 --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + CreateSFTPFn: createSFTPOK, + }, + wantOutput: "Created SFTP logging endpoint log (service 123 version 4)", + }, + { + args: args("logging sftp create --service-id 123 --version 1 --name log --address example.com --user user --ssh-known-hosts knownHosts() --port 80 --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + CreateSFTPFn: createSFTPError, + }, + wantError: errTest.Error(), + }, + { + args: args("logging sftp create --service-id 123 --version 1 --name log --address example.com --user anonymous --ssh-known-hosts knownHosts() --port 80 --compression-codec zstd --gzip-level 9 --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + }, + wantError: "error parsing arguments: the --compression-codec flag is mutually exclusive with the --gzip-level flag", + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertStringContains(t, stdout.String(), testcase.wantOutput) + }) + } +} + +func TestSFTPList(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging sftp list --service-id 123 --version 1"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListSFTPsFn: listSFTPsOK, + }, + wantOutput: listSFTPsShortOutput, + }, + { + args: args("logging sftp list --service-id 123 --version 1 --verbose"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListSFTPsFn: listSFTPsOK, + }, + wantOutput: listSFTPsVerboseOutput, + }, + { + args: args("logging sftp list --service-id 123 --version 1 -v"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListSFTPsFn: listSFTPsOK, + }, + wantOutput: listSFTPsVerboseOutput, + }, + { + args: args("logging sftp --verbose list --service-id 123 --version 1"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListSFTPsFn: listSFTPsOK, + }, + wantOutput: listSFTPsVerboseOutput, + }, + { + args: args("logging -v sftp list --service-id 123 --version 1"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListSFTPsFn: listSFTPsOK, + }, + wantOutput: listSFTPsVerboseOutput, + }, + { + args: args("logging sftp list --service-id 123 --version 1"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListSFTPsFn: listSFTPsError, + }, + wantError: errTest.Error(), + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertString(t, testcase.wantOutput, stdout.String()) + }) + } +} + +func TestSFTPDescribe(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging sftp describe --service-id 123 --version 1"), + wantError: "error parsing arguments: required flag --name not provided", + }, + { + args: args("logging sftp describe --service-id 123 --version 1 --name logs"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + GetSFTPFn: getSFTPError, + }, + wantError: errTest.Error(), + }, + { + args: args("logging sftp describe --service-id 123 --version 1 --name logs"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + GetSFTPFn: getSFTPOK, + }, + wantOutput: describeSFTPOutput, + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertString(t, testcase.wantOutput, stdout.String()) + }) + } +} + +func TestSFTPUpdate(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging sftp update --service-id 123 --version 1 --new-name log"), + wantError: "error parsing arguments: required flag --name not provided", + }, + { + args: args("logging sftp update --service-id 123 --version 1 --name logs --new-name log --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + UpdateSFTPFn: updateSFTPError, + }, + wantError: errTest.Error(), + }, + { + args: args("logging sftp update --service-id 123 --version 1 --name logs --new-name log --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + UpdateSFTPFn: updateSFTPOK, + }, + wantOutput: "Updated SFTP logging endpoint log (service 123 version 4)", + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertStringContains(t, stdout.String(), testcase.wantOutput) + }) + } +} + +func TestSFTPDelete(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging sftp delete --service-id 123 --version 1"), + wantError: "error parsing arguments: required flag --name not provided", + }, + { + args: args("logging sftp delete --service-id 123 --version 1 --name logs --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + DeleteSFTPFn: deleteSFTPError, + }, + wantError: errTest.Error(), + }, + { + args: args("logging sftp delete --service-id 123 --version 1 --name logs --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + DeleteSFTPFn: deleteSFTPOK, + }, + wantOutput: "Deleted SFTP logging endpoint logs (service 123 version 4)", + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertStringContains(t, stdout.String(), testcase.wantOutput) + }) + } +} + +var errTest = errors.New("fixture error") + +func createSFTPOK(i *fastly.CreateSFTPInput) (*fastly.SFTP, error) { + s := fastly.SFTP{ + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + CompressionCodec: fastly.ToPointer("zstd"), + } + + if i.Name != nil { + s.Name = i.Name + } + + return &s, nil +} + +func createSFTPError(_ *fastly.CreateSFTPInput) (*fastly.SFTP, error) { + return nil, errTest +} + +func listSFTPsOK(i *fastly.ListSFTPsInput) ([]*fastly.SFTP, error) { + return []*fastly.SFTP{ + { + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("logs"), + Address: fastly.ToPointer("127.0.0.1"), + Port: fastly.ToPointer(514), + User: fastly.ToPointer("user"), + Password: fastly.ToPointer("password"), + PublicKey: fastly.ToPointer(pgpPublicKey()), + SecretKey: fastly.ToPointer(sshPrivateKey()), + SSHKnownHosts: fastly.ToPointer(knownHosts()), + Path: fastly.ToPointer("/logs"), + Period: fastly.ToPointer(3600), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + FormatVersion: fastly.ToPointer(2), + MessageType: fastly.ToPointer("classic"), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + TimestampFormat: fastly.ToPointer("%Y-%m-%dT%H:%M:%S.000"), + Placement: fastly.ToPointer("none"), + CompressionCodec: fastly.ToPointer("zstd"), + }, + { + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("analytics"), + Address: fastly.ToPointer("example.com"), + Port: fastly.ToPointer(123), + User: fastly.ToPointer("user"), + Password: fastly.ToPointer("password"), + PublicKey: fastly.ToPointer(pgpPublicKey()), + SecretKey: fastly.ToPointer(sshPrivateKey()), + SSHKnownHosts: fastly.ToPointer(knownHosts()), + Path: fastly.ToPointer("/analytics"), + Period: fastly.ToPointer(3600), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + MessageType: fastly.ToPointer("classic"), + FormatVersion: fastly.ToPointer(2), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + TimestampFormat: fastly.ToPointer("%Y-%m-%dT%H:%M:%S.000"), + Placement: fastly.ToPointer("none"), + CompressionCodec: fastly.ToPointer("zstd"), + }, + }, nil +} + +func listSFTPsError(_ *fastly.ListSFTPsInput) ([]*fastly.SFTP, error) { + return nil, errTest +} + +var listSFTPsShortOutput = strings.TrimSpace(` +SERVICE VERSION NAME +123 1 logs +123 1 analytics +`) + "\n" + +var listSFTPsVerboseOutput = strings.TrimSpace(` +Fastly API endpoint: https://api.fastly.com +Fastly API token provided via config file (profile: user) + +Service ID (via --service-id): 123 + +Version: 1 + SFTP 1/2 + Service ID: 123 + Version: 1 + Name: logs + Address: 127.0.0.1 + Port: 514 + User: user + Password: password + Public key: `+pgpPublicKey()+` + Secret key: `+sshPrivateKey()+` + SSH known hosts: `+knownHosts()+` + Path: /logs + Period: 3600 + GZip level: 0 + Format: %h %l %u %t "%r" %>s %b + Format version: 2 + Message type: classic + Response condition: Prevent default logging + Timestamp format: %Y-%m-%dT%H:%M:%S.000 + Placement: none + Compression codec: zstd + SFTP 2/2 + Service ID: 123 + Version: 1 + Name: analytics + Address: example.com + Port: 123 + User: user + Password: password + Public key: `+pgpPublicKey()+` + Secret key: `+sshPrivateKey()+` + SSH known hosts: `+knownHosts()+` + Path: /analytics + Period: 3600 + GZip level: 0 + Format: %h %l %u %t "%r" %>s %b + Format version: 2 + Message type: classic + Response condition: Prevent default logging + Timestamp format: %Y-%m-%dT%H:%M:%S.000 + Placement: none + Compression codec: zstd +`) + "\n\n" + +func getSFTPOK(i *fastly.GetSFTPInput) (*fastly.SFTP, error) { + return &fastly.SFTP{ + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("logs"), + Address: fastly.ToPointer("example.com"), + Port: fastly.ToPointer(514), + User: fastly.ToPointer("user"), + Password: fastly.ToPointer("password"), + PublicKey: fastly.ToPointer(pgpPublicKey()), + SecretKey: fastly.ToPointer(sshPrivateKey()), + SSHKnownHosts: fastly.ToPointer(knownHosts()), + Path: fastly.ToPointer("/logs"), + Period: fastly.ToPointer(3600), + GzipLevel: fastly.ToPointer(2), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + FormatVersion: fastly.ToPointer(2), + MessageType: fastly.ToPointer("classic"), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + TimestampFormat: fastly.ToPointer("%Y-%m-%dT%H:%M:%S.000"), + Placement: fastly.ToPointer("none"), + CompressionCodec: fastly.ToPointer("zstd"), + }, nil +} + +func getSFTPError(_ *fastly.GetSFTPInput) (*fastly.SFTP, error) { + return nil, errTest +} + +var describeSFTPOutput = ` +Address: example.com +Compression codec: zstd +Format: %h %l %u %t "%r" %>s %b +Format version: 2 +GZip level: 2 +Message type: classic +Name: logs +Password: password +Path: /logs +Period: 3600 +Placement: none +Port: 514 +Public key: ` + pgpPublicKey() + ` +Response condition: Prevent default logging +SSH known hosts: ` + knownHosts() + ` +Secret key: ` + sshPrivateKey() + ` +Service ID: 123 +Timestamp format: %Y-%m-%dT%H:%M:%S.000 +User: user +Version: 1 +` + +func updateSFTPOK(i *fastly.UpdateSFTPInput) (*fastly.SFTP, error) { + return &fastly.SFTP{ + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("log"), + Address: fastly.ToPointer("example.com"), + Port: fastly.ToPointer(514), + User: fastly.ToPointer("user"), + Password: fastly.ToPointer("password"), + PublicKey: fastly.ToPointer(pgpPublicKey()), + SecretKey: fastly.ToPointer(sshPrivateKey()), + SSHKnownHosts: fastly.ToPointer(knownHosts()), + Path: fastly.ToPointer("/logs"), + Period: fastly.ToPointer(3600), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + FormatVersion: fastly.ToPointer(2), + MessageType: fastly.ToPointer("classic"), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + TimestampFormat: fastly.ToPointer("%Y-%m-%dT%H:%M:%S.000"), + Placement: fastly.ToPointer("none"), + CompressionCodec: fastly.ToPointer("zstd"), + }, nil +} + +func updateSFTPError(_ *fastly.UpdateSFTPInput) (*fastly.SFTP, error) { + return nil, errTest +} + +func deleteSFTPOK(_ *fastly.DeleteSFTPInput) error { + return nil +} + +func deleteSFTPError(_ *fastly.DeleteSFTPInput) error { + return errTest +} + +// knownHosts returns sample known hosts suitable for testing. +func knownHosts() string { + return strings.TrimSpace(` +example.com +127.0.0.1 +`) +} + +// pgpPublicKey returns a PEM encoded PGP public key suitable for testing. +func pgpPublicKey() string { + return strings.TrimSpace(`-----BEGIN PGP PUBLIC KEY BLOCK----- +mQENBFyUD8sBCACyFnB39AuuTygseek+eA4fo0cgwva6/FSjnWq7riouQee8GgQ/ +ibXTRyv4iVlwI12GswvMTIy7zNvs1R54i0qvsLr+IZ4GVGJqs6ZJnvQcqe3xPoR4 +8AnBfw90o32r/LuHf6QCJXi+AEu35koNlNAvLJ2B+KACaNB7N0EeWmqpV/1V2k9p +lDYk+th7LcCuaFNGqKS/PrMnnMqR6VDLCjHhNx4KR79b0Twm/2qp6an3hyNRu8Gn +dwxpf1/BUu3JWf+LqkN4Y3mbOmSUL3MaJNvyQguUzTfS0P0uGuBDHrJCVkMZCzDB +89ag55jCPHyGeHBTd02gHMWzsg3WMBWvCsrzABEBAAG0JXRlcnJhZm9ybSAodGVz +dCkgPHRlc3RAdGVycmFmb3JtLmNvbT6JAU4EEwEIADgWIQSHYyc6Kj9l6HzQsau6 +vFFc9jxV/wUCXJQPywIbAwULCQgHAgYVCgkICwIEFgIDAQIeAQIXgAAKCRC6vFFc +9jxV/815CAClb32OxV7wG01yF97TzlyTl8TnvjMtoG29Mw4nSyg+mjM3b8N7iXm9 +OLX59fbDAWtBSldSZE22RXd3CvlFOG/EnKBXSjBtEqfyxYSnyOPkMPBYWGL/ApkX +SvPYJ4LKdvipYToKFh3y9kk2gk1DcDBDyaaHvR+3rv1u3aoy7/s2EltAfDS3ZQIq +7/cWTLJml/lleeB/Y6rPj8xqeCYhE5ahw9gsV/Mdqatl24V9Tks30iijx0Hhw+Gx +kATUikMGr2GDVqoIRga5kXI7CzYff4rkc0Twn47fMHHHe/KY9M2yVnMHUXmAZwbG +M1cMI/NH1DjevCKdGBLcRJlhuLPKF/anuQENBFyUD8sBCADIpd7r7GuPd6n/Ikxe +u6h7umV6IIPoAm88xCYpTbSZiaK30Svh6Ywra9jfE2KlU9o6Y/art8ip0VJ3m07L +4RSfSpnzqgSwdjSq5hNour2Fo/BzYhK7yaz2AzVSbe33R0+RYhb4b/6N+bKbjwGF +ftCsqVFMH+PyvYkLbvxyQrHlA9woAZaNThI1ztO5rGSnGUR8xt84eup28WIFKg0K +UEGUcTzz+8QGAwAra+0ewPXo/AkO+8BvZjDidP417u6gpBHOJ9qYIcO9FxHeqFyu +YrjlrxowEgXn5wO8xuNz6Vu1vhHGDHGDsRbZF8pv1d5O+0F1G7ttZ2GRRgVBZPwi +kiyRABEBAAGJATYEGAEIACAWIQSHYyc6Kj9l6HzQsau6vFFc9jxV/wUCXJQPywIb +DAAKCRC6vFFc9jxV/9YOCACe8qmOSnKQpQfW+PqYOqo3dt7JyweTs3FkD6NT8Zml +dYy/vkstbTjPpX6aTvUZjkb46BVi7AOneVHpD5GBqvRsZ9iVgDYHaehmLCdKiG5L +3Tp90NN+QY5WDbsGmsyk6+6ZMYejb4qYfweQeduOj27aavCJdLkCYMoRKfcFYI8c +FaNmEfKKy/r1PO20NXEG6t9t05K/frHy6ZG8bCNYdpagfFVot47r9JaQqWlTNtIR +5+zkkSq/eG9BEtRij3a6cTdQbktdBzx2KBeI0PYc1vlZR0LpuFKZqY9vlE6vTGLR +wMfrTEOvx0NxUM3rpaCgEmuWbB1G1Hu371oyr4srrr+N +=28dr +-----END PGP PUBLIC KEY BLOCK----- +`) +} + +// sshPrivateKey returns a private key suitable for testing. +func sshPrivateKey() string { + return strings.TrimSpace(`-----BEGIN RSA PRIVATE KEY----- +MIICXAIBAAKBgQDDo+/YbQ1cZVoRhZ/bbQtPxpycDS5Lty+M8e5swCKpmo0/Eym2 +KrVpEVMoU8eGtwVRvGDR2LtmFKvd86QUWkn2V3lYgY66SNj9n4R/YSDT4/GRkg+4 +Egi++ihpZA+SAIODF4+l1bh/FFu0XUpQLXvJ4Tm0++7bm3tEq+XQr9znrwIDAQAB +AoGAfDa374e9te47s2hNyLmBNxN5F7Nes4AJVsm8gZuz5k9UYrm+AAU5zQ3M6IvY +4PWPEQgzyMh8oyF4xaENikaRMhSMfinUmTd979cHbOM6cEKPk28oQcIybsdSzX7G +ZWRh65Ze1DUmBe6R2BUh3Zn4lq9PsqB0TeZeV7Xo/VaIpFECQQDoznQi8HOY8MNM +7ZDdRhFAkS2X5OGqXOjYdLABGNvJhajgoRsTbgDyJG83qn6yYq7wEHYlMddGZ3ln +RLnpsThjAkEA1yGXae8WURFEqjp5dMLBxU07apKvEF4zK1OxZ0VjIOJdIpoRBBuL +IthGBuMrfbF1W5tlmQlj5ik0KhVpBZoHRQJAZP7DdTDZBT1VjHb3RHcUHu2cWOvL +VkvuG5ErlZ5CIv+gDqr1gw1SzbkuoniNdDfJao3Jo0Mm//z9tuYivRXLvwJBALG3 +Wzi0vI/Nnxas5YayGJaf3XSFpj70QnsJUWUJagFRXjTmZyYohsELPpYT9eqIvXUm +o0BQBImvAhu9whtRia0CQCFdDHdNnyyzKH8vC0NsEN65h3Bp2KEPkv8SOV27ZRR2 +xIGqLusk3y+yzbueLZJ117osdB1Owr19fvAHR7vq6Mw= +-----END RSA PRIVATE KEY-----`) +} diff --git a/pkg/commands/logging/sftp/sftp_test.go b/pkg/commands/logging/sftp/sftp_test.go new file mode 100644 index 000000000..f91ecebf2 --- /dev/null +++ b/pkg/commands/logging/sftp/sftp_test.go @@ -0,0 +1,386 @@ +package sftp_test + +import ( + "bytes" + "testing" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/logging/sftp" + "github.com/fastly/cli/pkg/config" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" +) + +func TestCreateSFTPInput(t *testing.T) { + for _, testcase := range []struct { + name string + cmd *sftp.CreateCommand + want *fastly.CreateSFTPInput + wantError string + }{ + { + name: "required values set flag serviceID", + cmd: createCommandRequired(), + want: &fastly.CreateSFTPInput{ + ServiceID: "123", + ServiceVersion: 4, + Name: fastly.ToPointer("log"), + Address: fastly.ToPointer("127.0.0.1"), + User: fastly.ToPointer("user"), + SSHKnownHosts: fastly.ToPointer(knownHosts()), + }, + }, + { + name: "all values set flag serviceID", + cmd: createCommandAll(), + want: &fastly.CreateSFTPInput{ + ServiceID: "123", + ServiceVersion: 4, + Name: fastly.ToPointer("log"), + Address: fastly.ToPointer("127.0.0.1"), + Port: fastly.ToPointer(80), + User: fastly.ToPointer("user"), + Password: fastly.ToPointer("password"), + PublicKey: fastly.ToPointer(pgpPublicKey()), + SecretKey: fastly.ToPointer(sshPrivateKey()), + SSHKnownHosts: fastly.ToPointer(knownHosts()), + Path: fastly.ToPointer("/log"), + Period: fastly.ToPointer(3600), + FormatVersion: fastly.ToPointer(2), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + MessageType: fastly.ToPointer("classic"), + TimestampFormat: fastly.ToPointer("%Y-%m-%dT%H:%M:%S.000"), + Placement: fastly.ToPointer("none"), + CompressionCodec: fastly.ToPointer("zstd"), + }, + }, + { + name: "error missing serviceID", + cmd: createCommandMissingServiceID(), + want: nil, + wantError: errors.ErrNoServiceID.Error(), + }, + } { + t.Run(testcase.name, func(t *testing.T) { + var bs []byte + out := bytes.NewBuffer(bs) + verboseMode := true + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + AutoCloneFlag: testcase.cmd.AutoClone, + APIClient: testcase.cmd.Globals.APIClient, + Manifest: testcase.cmd.Manifest, + Out: out, + ServiceVersionFlag: testcase.cmd.ServiceVersion, + VerboseMode: verboseMode, + }) + + switch { + case err != nil && testcase.wantError == "": + t.Fatalf("unexpected error getting service details: %v", err) + return + case err != nil && testcase.wantError != "": + testutil.AssertErrorContains(t, err, testcase.wantError) + return + case err == nil && testcase.wantError != "": + t.Fatalf("expected error, have nil (service details: %s, %d)", serviceID, serviceVersion.Number) + case err == nil && testcase.wantError == "": + have, err := testcase.cmd.ConstructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertEqual(t, testcase.want, have) + } + }) + } +} + +func TestUpdateSFTPInput(t *testing.T) { + scenarios := []struct { + name string + cmd *sftp.UpdateCommand + api mock.API + want *fastly.UpdateSFTPInput + wantError string + }{ + { + name: "all values set flag serviceID", + cmd: updateCommandAll(), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + GetSFTPFn: getSFTPOK, + }, + want: &fastly.UpdateSFTPInput{ + ServiceID: "123", + ServiceVersion: 4, + Name: "log", + NewName: fastly.ToPointer("new1"), + Address: fastly.ToPointer("new2"), + Port: fastly.ToPointer(81), + User: fastly.ToPointer("new3"), + SSHKnownHosts: fastly.ToPointer("new4"), + Password: fastly.ToPointer("new5"), + PublicKey: fastly.ToPointer("new6"), + SecretKey: fastly.ToPointer("new7"), + Path: fastly.ToPointer("new8"), + Period: fastly.ToPointer(3601), + FormatVersion: fastly.ToPointer(3), + GzipLevel: fastly.ToPointer(0), + Format: fastly.ToPointer("new9"), + ResponseCondition: fastly.ToPointer("new10"), + TimestampFormat: fastly.ToPointer("new11"), + Placement: fastly.ToPointer("new12"), + MessageType: fastly.ToPointer("new13"), + CompressionCodec: fastly.ToPointer("new14"), + }, + }, + { + name: "no updates", + cmd: updateCommandNoUpdates(), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + GetSFTPFn: getSFTPOK, + }, + want: &fastly.UpdateSFTPInput{ + ServiceID: "123", + ServiceVersion: 4, + Name: "log", + }, + }, + { + name: "error missing serviceID", + cmd: updateCommandMissingServiceID(), + want: nil, + wantError: errors.ErrNoServiceID.Error(), + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(testcase.name, func(t *testing.T) { + testcase.cmd.Globals.APIClient = testcase.api + + var bs []byte + out := bytes.NewBuffer(bs) + verboseMode := true + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + AutoCloneFlag: testcase.cmd.AutoClone, + APIClient: testcase.api, + Manifest: testcase.cmd.Manifest, + Out: out, + ServiceVersionFlag: testcase.cmd.ServiceVersion, + VerboseMode: verboseMode, + }) + + switch { + case err != nil && testcase.wantError == "": + t.Fatalf("unexpected error getting service details: %v", err) + return + case err != nil && testcase.wantError != "": + testutil.AssertErrorContains(t, err, testcase.wantError) + return + case err == nil && testcase.wantError != "": + t.Fatalf("expected error, have nil (service details: %s, %d)", serviceID, serviceVersion.Number) + case err == nil && testcase.wantError == "": + have, err := testcase.cmd.ConstructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertEqual(t, testcase.want, have) + } + }) + } +} + +func createCommandRequired() *sftp.CreateCommand { + var b bytes.Buffer + + g := global.Data{ + Config: config.File{}, + Env: config.Environment{}, + Output: &b, + } + g.APIClient, _ = mock.APIClient(mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + })("token", "endpoint", false) + + return &sftp.CreateCommand{ + Base: argparser.Base{ + Globals: &g, + }, + Manifest: manifest.Data{ + Flag: manifest.Flag{ + ServiceID: "123", + }, + }, + ServiceVersion: argparser.OptionalServiceVersion{ + OptionalString: argparser.OptionalString{Value: "1"}, + }, + AutoClone: argparser.OptionalAutoClone{ + OptionalBool: argparser.OptionalBool{ + Optional: argparser.Optional{ + WasSet: true, + }, + Value: true, + }, + }, + EndpointName: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "log"}, + Address: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "127.0.0.1"}, + User: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "user"}, + SSHKnownHosts: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: knownHosts()}, + } +} + +func createCommandAll() *sftp.CreateCommand { + var b bytes.Buffer + + g := global.Data{ + Config: config.File{}, + Env: config.Environment{}, + Output: &b, + } + g.APIClient, _ = mock.APIClient(mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + })("token", "endpoint", false) + + return &sftp.CreateCommand{ + Base: argparser.Base{ + Globals: &g, + }, + Manifest: manifest.Data{ + Flag: manifest.Flag{ + ServiceID: "123", + }, + }, + ServiceVersion: argparser.OptionalServiceVersion{ + OptionalString: argparser.OptionalString{Value: "1"}, + }, + AutoClone: argparser.OptionalAutoClone{ + OptionalBool: argparser.OptionalBool{ + Optional: argparser.Optional{ + WasSet: true, + }, + Value: true, + }, + }, + EndpointName: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "log"}, + Address: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "127.0.0.1"}, + User: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "user"}, + SSHKnownHosts: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: knownHosts()}, + Port: argparser.OptionalInt{Optional: argparser.Optional{WasSet: true}, Value: 80}, + Password: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "password"}, + PublicKey: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: pgpPublicKey()}, + SecretKey: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: sshPrivateKey()}, + Path: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "/log"}, + Period: argparser.OptionalInt{Optional: argparser.Optional{WasSet: true}, Value: 3600}, + Format: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: `%h %l %u %t "%r" %>s %b`}, + FormatVersion: argparser.OptionalInt{Optional: argparser.Optional{WasSet: true}, Value: 2}, + MessageType: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "classic"}, + ResponseCondition: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "Prevent default logging"}, + TimestampFormat: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "%Y-%m-%dT%H:%M:%S.000"}, + Placement: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "none"}, + CompressionCodec: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "zstd"}, + } +} + +func createCommandMissingServiceID() *sftp.CreateCommand { + res := createCommandAll() + res.Manifest = manifest.Data{} + return res +} + +func updateCommandNoUpdates() *sftp.UpdateCommand { + var b bytes.Buffer + + g := global.Data{ + Config: config.File{}, + Env: config.Environment{}, + Output: &b, + } + + return &sftp.UpdateCommand{ + Base: argparser.Base{ + Globals: &g, + }, + Manifest: manifest.Data{ + Flag: manifest.Flag{ + ServiceID: "123", + }, + }, + EndpointName: "log", + ServiceVersion: argparser.OptionalServiceVersion{ + OptionalString: argparser.OptionalString{Value: "1"}, + }, + AutoClone: argparser.OptionalAutoClone{ + OptionalBool: argparser.OptionalBool{ + Optional: argparser.Optional{ + WasSet: true, + }, + Value: true, + }, + }, + } +} + +func updateCommandAll() *sftp.UpdateCommand { + var b bytes.Buffer + + g := global.Data{ + Config: config.File{}, + Env: config.Environment{}, + Output: &b, + } + + return &sftp.UpdateCommand{ + Base: argparser.Base{ + Globals: &g, + }, + Manifest: manifest.Data{ + Flag: manifest.Flag{ + ServiceID: "123", + }, + }, + EndpointName: "log", + ServiceVersion: argparser.OptionalServiceVersion{ + OptionalString: argparser.OptionalString{Value: "1"}, + }, + AutoClone: argparser.OptionalAutoClone{ + OptionalBool: argparser.OptionalBool{ + Optional: argparser.Optional{ + WasSet: true, + }, + Value: true, + }, + }, + NewName: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new1"}, + Address: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new2"}, + User: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new3"}, + SSHKnownHosts: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new4"}, + Port: argparser.OptionalInt{Optional: argparser.Optional{WasSet: true}, Value: 81}, + Password: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new5"}, + PublicKey: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new6"}, + SecretKey: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new7"}, + Path: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new8"}, + Period: argparser.OptionalInt{Optional: argparser.Optional{WasSet: true}, Value: 3601}, + Format: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new9"}, + FormatVersion: argparser.OptionalInt{Optional: argparser.Optional{WasSet: true}, Value: 3}, + GzipLevel: argparser.OptionalInt{Optional: argparser.Optional{WasSet: true}, Value: 0}, + ResponseCondition: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new10"}, + TimestampFormat: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new11"}, + Placement: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new12"}, + MessageType: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new13"}, + CompressionCodec: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new14"}, + } +} + +func updateCommandMissingServiceID() *sftp.UpdateCommand { + res := updateCommandAll() + res.Manifest = manifest.Data{} + return res +} diff --git a/pkg/commands/logging/sftp/update.go b/pkg/commands/logging/sftp/update.go new file mode 100644 index 000000000..c79a1f264 --- /dev/null +++ b/pkg/commands/logging/sftp/update.go @@ -0,0 +1,229 @@ +package sftp + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/logging/common" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/text" +) + +// UpdateCommand calls the Fastly API to update an SFTP logging endpoint. +type UpdateCommand struct { + argparser.Base + Manifest manifest.Data + + // Required. + EndpointName string + ServiceName argparser.OptionalServiceNameID + ServiceVersion argparser.OptionalServiceVersion + + // Optional. + AutoClone argparser.OptionalAutoClone + NewName argparser.OptionalString + Address argparser.OptionalString + Port argparser.OptionalInt + PublicKey argparser.OptionalString + SecretKey argparser.OptionalString + SSHKnownHosts argparser.OptionalString + User argparser.OptionalString + Password argparser.OptionalString + Path argparser.OptionalString + Period argparser.OptionalInt + FormatVersion argparser.OptionalInt + GzipLevel argparser.OptionalInt + Format argparser.OptionalString + MessageType argparser.OptionalString + ResponseCondition argparser.OptionalString + TimestampFormat argparser.OptionalString + Placement argparser.OptionalString + CompressionCodec argparser.OptionalString +} + +// NewUpdateCommand returns a usable command registered under the parent. +func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { + c := UpdateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("update", "Update an SFTP logging endpoint on a Fastly service version") + + // Required. + c.CmdClause.Flag("name", "The name of the SFTP logging object").Short('n').Required().StringVar(&c.EndpointName) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.ServiceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.AutoClone.Set, + Dst: &c.AutoClone.Value, + }) + c.CmdClause.Flag("address", "The hostname or IPv4 address").Action(c.Address.Set).StringVar(&c.Address.Value) + common.CompressionCodec(c.CmdClause, &c.CompressionCodec) + c.CmdClause.Flag("new-name", "New name of the SFTP logging object").Action(c.NewName.Set).StringVar(&c.NewName.Value) + common.Format(c.CmdClause, &c.Format) + common.FormatVersion(c.CmdClause, &c.FormatVersion) + common.GzipLevel(c.CmdClause, &c.GzipLevel) + common.MessageType(c.CmdClause, &c.MessageType) + c.CmdClause.Flag("password", "The password for the server. If both password and secret_key are passed, secret_key will be used in preference").Action(c.Password.Set).StringVar(&c.Password.Value) + c.CmdClause.Flag("path", "The path to upload logs to. The directory must exist on the SFTP server before logs can be saved to it").Action(c.Path.Set).StringVar(&c.Path.Value) + common.Period(c.CmdClause, &c.Period) + common.Placement(c.CmdClause, &c.Placement) + c.CmdClause.Flag("port", "The port number").Action(c.Port.Set).IntVar(&c.Port.Value) + common.PublicKey(c.CmdClause, &c.PublicKey) + common.ResponseCondition(c.CmdClause, &c.ResponseCondition) + c.CmdClause.Flag("secret-key", "The SSH private key for the server. If both password and secret_key are passed, secret_key will be used in preference").Action(c.SecretKey.Set).StringVar(&c.SecretKey.Value) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.ServiceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.ServiceName.Value, + }) + c.CmdClause.Flag("ssh-known-hosts", "A list of host keys for all hosts we can connect to over SFTP").Action(c.SSHKnownHosts.Set).StringVar(&c.SSHKnownHosts.Value) + c.CmdClause.Flag("user", "The username for the server").Action(c.User.Set).StringVar(&c.User.Value) + common.TimestampFormat(c.CmdClause, &c.TimestampFormat) + return &c +} + +// ConstructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *UpdateCommand) ConstructInput(serviceID string, serviceVersion int) (*fastly.UpdateSFTPInput, error) { + input := fastly.UpdateSFTPInput{ + ServiceID: serviceID, + ServiceVersion: serviceVersion, + Name: c.EndpointName, + } + + if c.NewName.WasSet { + input.NewName = &c.NewName.Value + } + + if c.Address.WasSet { + input.Address = &c.Address.Value + } + + if c.Port.WasSet { + input.Port = &c.Port.Value + } + + if c.Password.WasSet { + input.Password = &c.Password.Value + } + + if c.PublicKey.WasSet { + input.PublicKey = &c.PublicKey.Value + } + + if c.SecretKey.WasSet { + input.SecretKey = &c.SecretKey.Value + } + + if c.SSHKnownHosts.WasSet { + input.SSHKnownHosts = &c.SSHKnownHosts.Value + } + + if c.User.WasSet { + input.User = &c.User.Value + } + + if c.Path.WasSet { + input.Path = &c.Path.Value + } + + if c.Period.WasSet { + input.Period = &c.Period.Value + } + + if c.Format.WasSet { + input.Format = fastly.ToPointer(argparser.Content(c.Format.Value)) + } + + if c.FormatVersion.WasSet { + input.FormatVersion = &c.FormatVersion.Value + } + + if c.GzipLevel.WasSet { + input.GzipLevel = &c.GzipLevel.Value + } + + if c.MessageType.WasSet { + input.MessageType = &c.MessageType.Value + } + + if c.ResponseCondition.WasSet { + input.ResponseCondition = &c.ResponseCondition.Value + } + + if c.TimestampFormat.WasSet { + input.TimestampFormat = &c.TimestampFormat.Value + } + + if c.Placement.WasSet { + input.Placement = &c.Placement.Value + } + + if c.CompressionCodec.WasSet { + input.CompressionCodec = &c.CompressionCodec.Value + } + + return &input, nil +} + +// Exec invokes the application logic for the command. +func (c *UpdateCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.AutoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.ServiceName, + ServiceVersionFlag: c.ServiceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + input, err := c.ConstructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + sftp, err := c.Globals.APIClient.UpdateSFTP(input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + text.Success(out, + "Updated SFTP logging endpoint %s (service %s version %d)", + fastly.ToValue(sftp.Name), + fastly.ToValue(sftp.ServiceID), + fastly.ToValue(sftp.ServiceVersion), + ) + return nil +} diff --git a/pkg/commands/logging/splunk/create.go b/pkg/commands/logging/splunk/create.go new file mode 100644 index 000000000..db813d7b1 --- /dev/null +++ b/pkg/commands/logging/splunk/create.go @@ -0,0 +1,183 @@ +package splunk + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/logging/common" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/text" +) + +// CreateCommand calls the Fastly API to create a Splunk logging endpoint. +type CreateCommand struct { + argparser.Base + Manifest manifest.Data + + // Required. + ServiceName argparser.OptionalServiceNameID + ServiceVersion argparser.OptionalServiceVersion + + // Optional. + AutoClone argparser.OptionalAutoClone + EndpointName argparser.OptionalString // Can't shadow argparser.Base method Name(). + Format argparser.OptionalString + FormatVersion argparser.OptionalInt + Placement argparser.OptionalString + ResponseCondition argparser.OptionalString + TimestampFormat argparser.OptionalString + TLSCACert argparser.OptionalString + TLSClientCert argparser.OptionalString + TLSClientKey argparser.OptionalString + TLSHostname argparser.OptionalString + Token argparser.OptionalString + URL argparser.OptionalString +} + +// NewCreateCommand returns a usable command registered under the parent. +func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { + c := CreateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("create", "Create a Splunk logging endpoint on a Fastly service version").Alias("add") + + // Required. + c.CmdClause.Flag("name", "The name of the Splunk logging object. Used as a primary key for API access").Short('n').Action(c.EndpointName.Set).StringVar(&c.EndpointName.Value) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.ServiceVersion.Value, + Required: true, + }) + + // Optional. + c.CmdClause.Flag("auth-token", "A Splunk token for use in posting logs over HTTP to your collector").Action(c.Token.Set).StringVar(&c.Token.Value) + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.AutoClone.Set, + Dst: &c.AutoClone.Value, + }) + common.Format(c.CmdClause, &c.Format) + common.FormatVersion(c.CmdClause, &c.FormatVersion) + common.Placement(c.CmdClause, &c.Placement) + common.ResponseCondition(c.CmdClause, &c.ResponseCondition) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.ServiceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.ServiceName.Value, + }) + common.TLSCACert(c.CmdClause, &c.TLSCACert) + common.TLSClientCert(c.CmdClause, &c.TLSClientCert) + common.TLSClientKey(c.CmdClause, &c.TLSClientKey) + common.TLSHostname(c.CmdClause, &c.TLSHostname) + c.CmdClause.Flag("url", "The URL to POST to").Action(c.URL.Set).StringVar(&c.URL.Value) + return &c +} + +// ConstructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *CreateCommand) ConstructInput(serviceID string, serviceVersion int) (*fastly.CreateSplunkInput, error) { + var input fastly.CreateSplunkInput + + input.ServiceID = serviceID + input.ServiceVersion = serviceVersion + if c.EndpointName.WasSet { + input.Name = &c.EndpointName.Value + } + if c.URL.WasSet { + input.URL = &c.URL.Value + } + + if c.TLSHostname.WasSet { + input.TLSHostname = &c.TLSHostname.Value + } + + if c.TLSCACert.WasSet { + input.TLSCACert = &c.TLSCACert.Value + } + + if c.TLSClientCert.WasSet { + input.TLSClientCert = &c.TLSClientCert.Value + } + + if c.TLSClientKey.WasSet { + input.TLSClientKey = &c.TLSClientKey.Value + } + + if c.Format.WasSet { + input.Format = fastly.ToPointer(argparser.Content(c.Format.Value)) + } + + if c.FormatVersion.WasSet { + input.FormatVersion = &c.FormatVersion.Value + } + + if c.ResponseCondition.WasSet { + input.ResponseCondition = &c.ResponseCondition.Value + } + + if c.Token.WasSet { + input.Token = &c.Token.Value + } + + if c.Placement.WasSet { + input.Placement = &c.Placement.Value + } + + return &input, nil +} + +// Exec invokes the application logic for the command. +func (c *CreateCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.AutoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.ServiceName, + ServiceVersionFlag: c.ServiceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + input, err := c.ConstructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + d, err := c.Globals.APIClient.CreateSplunk(input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + text.Success(out, + "Created Splunk logging endpoint %s (service %s version %d)", + fastly.ToValue(d.Name), + fastly.ToValue(d.ServiceID), + fastly.ToValue(d.ServiceVersion), + ) + return nil +} diff --git a/pkg/commands/logging/splunk/delete.go b/pkg/commands/logging/splunk/delete.go new file mode 100644 index 000000000..cba6fdf47 --- /dev/null +++ b/pkg/commands/logging/splunk/delete.go @@ -0,0 +1,94 @@ +package splunk + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// DeleteCommand calls the Fastly API to delete a Splunk logging endpoint. +type DeleteCommand struct { + argparser.Base + Input fastly.DeleteSplunkInput + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion + autoClone argparser.OptionalAutoClone +} + +// NewDeleteCommand returns a usable command registered under the parent. +func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { + c := DeleteCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("delete", "Delete a Splunk logging endpoint on a Fastly service version").Alias("remove") + + // Required. + c.CmdClause.Flag("name", "The name of the Splunk logging object").Short('n').Required().StringVar(&c.Input.Name) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.autoClone.Set, + Dst: &c.autoClone.Value, + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + return &c +} + +// Exec invokes the application logic for the command. +func (c *DeleteCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.autoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + c.Input.ServiceID = serviceID + c.Input.ServiceVersion = fastly.ToValue(serviceVersion.Number) + + if err := c.Globals.APIClient.DeleteSplunk(&c.Input); err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + text.Success(out, "Deleted Splunk logging endpoint %s (service %s version %d)", c.Input.Name, c.Input.ServiceID, c.Input.ServiceVersion) + return nil +} diff --git a/pkg/commands/logging/splunk/describe.go b/pkg/commands/logging/splunk/describe.go new file mode 100644 index 000000000..6054111d7 --- /dev/null +++ b/pkg/commands/logging/splunk/describe.go @@ -0,0 +1,114 @@ +package splunk + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// DescribeCommand calls the Fastly API to describe a Splunk logging endpoint. +type DescribeCommand struct { + argparser.Base + argparser.JSONOutput + + Input fastly.GetSplunkInput + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion +} + +// NewDescribeCommand returns a usable command registered under the parent. +func NewDescribeCommand(parent argparser.Registerer, g *global.Data) *DescribeCommand { + c := DescribeCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("describe", "Show detailed information about a Splunk logging endpoint on a Fastly service version").Alias("get") + + // Required. + c.CmdClause.Flag("name", "The name of the Splunk logging object").Short('n').Required().StringVar(&c.Input.Name) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + return &c +} + +// Exec invokes the application logic for the command. +func (c *DescribeCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fsterr.ServiceVersion(serviceVersion), + }) + return err + } + + c.Input.ServiceID = serviceID + c.Input.ServiceVersion = fastly.ToValue(serviceVersion.Number) + + o, err := c.Globals.APIClient.GetSplunk(&c.Input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + lines := text.Lines{ + "Format version": fastly.ToValue(o.FormatVersion), + "Format": fastly.ToValue(o.Format), + "Name": fastly.ToValue(o.Name), + "Placement": fastly.ToValue(o.Placement), + "Response condition": fastly.ToValue(o.ResponseCondition), + "TLS CA certificate": fastly.ToValue(o.TLSCACert), + "TLS client certificate": fastly.ToValue(o.TLSClientCert), + "TLS client key": fastly.ToValue(o.TLSClientKey), + "TLS hostname": fastly.ToValue(o.TLSHostname), + "Token": fastly.ToValue(o.Token), + "URL": fastly.ToValue(o.URL), + "Version": fastly.ToValue(o.ServiceVersion), + } + if !c.Globals.Verbose() { + lines["Service ID"] = fastly.ToValue(o.ServiceID) + } + text.PrintLines(out, lines) + + return nil +} diff --git a/pkg/logging/splunk/doc.go b/pkg/commands/logging/splunk/doc.go similarity index 100% rename from pkg/logging/splunk/doc.go rename to pkg/commands/logging/splunk/doc.go diff --git a/pkg/commands/logging/splunk/list.go b/pkg/commands/logging/splunk/list.go new file mode 100644 index 000000000..d9f64116e --- /dev/null +++ b/pkg/commands/logging/splunk/list.go @@ -0,0 +1,128 @@ +package splunk + +import ( + "fmt" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// ListCommand calls the Fastly API to list Splunk logging endpoints. +type ListCommand struct { + argparser.Base + argparser.JSONOutput + + Input fastly.ListSplunksInput + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion +} + +// NewListCommand returns a usable command registered under the parent. +func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { + c := ListCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("list", "List Splunk endpoints on a Fastly service version") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + return &c +} + +// Exec invokes the application logic for the command. +func (c *ListCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fsterr.ServiceVersion(serviceVersion), + }) + return err + } + + c.Input.ServiceID = serviceID + c.Input.ServiceVersion = fastly.ToValue(serviceVersion.Number) + + o, err := c.Globals.APIClient.ListSplunks(&c.Input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + if !c.Globals.Verbose() { + tw := text.NewTable(out) + tw.AddHeader("SERVICE", "VERSION", "NAME") + for _, splunk := range o { + tw.AddLine( + fastly.ToValue(splunk.ServiceID), + fastly.ToValue(splunk.ServiceVersion), + fastly.ToValue(splunk.Name), + ) + } + tw.Print() + return nil + } + + fmt.Fprintf(out, "Version: %d\n", c.Input.ServiceVersion) + for i, splunk := range o { + fmt.Fprintf(out, "\tSplunk %d/%d\n", i+1, len(o)) + fmt.Fprintf(out, "\t\tService ID: %s\n", fastly.ToValue(splunk.ServiceID)) + fmt.Fprintf(out, "\t\tVersion: %d\n", fastly.ToValue(splunk.ServiceVersion)) + fmt.Fprintf(out, "\t\tName: %s\n", fastly.ToValue(splunk.Name)) + fmt.Fprintf(out, "\t\tURL: %s\n", fastly.ToValue(splunk.URL)) + fmt.Fprintf(out, "\t\tToken: %s\n", fastly.ToValue(splunk.Token)) + fmt.Fprintf(out, "\t\tTLS CA certificate: %s\n", fastly.ToValue(splunk.TLSCACert)) + fmt.Fprintf(out, "\t\tTLS hostname: %s\n", fastly.ToValue(splunk.TLSHostname)) + fmt.Fprintf(out, "\t\tTLS client certificate: %s\n", fastly.ToValue(splunk.TLSClientCert)) + fmt.Fprintf(out, "\t\tTLS client key: %s\n", fastly.ToValue(splunk.TLSClientKey)) + fmt.Fprintf(out, "\t\tFormat: %s\n", fastly.ToValue(splunk.Format)) + fmt.Fprintf(out, "\t\tFormat version: %d\n", fastly.ToValue(splunk.FormatVersion)) + fmt.Fprintf(out, "\t\tResponse condition: %s\n", fastly.ToValue(splunk.ResponseCondition)) + fmt.Fprintf(out, "\t\tPlacement: %s\n", fastly.ToValue(splunk.Placement)) + } + fmt.Fprintln(out) + + return nil +} diff --git a/pkg/commands/logging/splunk/root.go b/pkg/commands/logging/splunk/root.go new file mode 100644 index 000000000..e450fa1a3 --- /dev/null +++ b/pkg/commands/logging/splunk/root.go @@ -0,0 +1,31 @@ +package splunk + +import ( + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" +) + +// RootCommand is the parent command for all subcommands in this package. +// It should be installed under the primary root command. +type RootCommand struct { + argparser.Base + // no flags +} + +// CommandName is the string to be used to invoke this command. +const CommandName = "splunk" + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent argparser.Registerer, g *global.Data) *RootCommand { + var c RootCommand + c.Globals = g + c.CmdClause = parent.Command(CommandName, "Manipulate Fastly service version Splunk logging endpoints") + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { + panic("unreachable") +} diff --git a/pkg/commands/logging/splunk/splunk_integration_test.go b/pkg/commands/logging/splunk/splunk_integration_test.go new file mode 100644 index 000000000..931f212f1 --- /dev/null +++ b/pkg/commands/logging/splunk/splunk_integration_test.go @@ -0,0 +1,435 @@ +package splunk_test + +import ( + "bytes" + "errors" + "io" + "strings" + "testing" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/app" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" +) + +func TestSplunkCreate(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging splunk create --service-id 123 --version 1 --name log --url example.com --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + CreateSplunkFn: createSplunkOK, + }, + wantOutput: "Created Splunk logging endpoint log (service 123 version 4)", + }, + { + args: args("logging splunk create --service-id 123 --version 1 --name log --url example.com --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + CreateSplunkFn: createSplunkError, + }, + wantError: errTest.Error(), + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertStringContains(t, stdout.String(), testcase.wantOutput) + }) + } +} + +func TestSplunkList(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging splunk list --service-id 123 --version 1"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListSplunksFn: listSplunksOK, + }, + wantOutput: listSplunksShortOutput, + }, + { + args: args("logging splunk list --service-id 123 --version 1 --verbose"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListSplunksFn: listSplunksOK, + }, + wantOutput: listSplunksVerboseOutput, + }, + { + args: args("logging splunk list --service-id 123 --version 1 -v"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListSplunksFn: listSplunksOK, + }, + wantOutput: listSplunksVerboseOutput, + }, + { + args: args("logging splunk --verbose list --service-id 123 --version 1"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListSplunksFn: listSplunksOK, + }, + wantOutput: listSplunksVerboseOutput, + }, + { + args: args("logging -v splunk list --service-id 123 --version 1"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListSplunksFn: listSplunksOK, + }, + wantOutput: listSplunksVerboseOutput, + }, + { + args: args("logging splunk list --service-id 123 --version 1"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListSplunksFn: listSplunksError, + }, + wantError: errTest.Error(), + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertString(t, testcase.wantOutput, stdout.String()) + }) + } +} + +func TestSplunkDescribe(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging splunk describe --service-id 123 --version 1"), + wantError: "error parsing arguments: required flag --name not provided", + }, + { + args: args("logging splunk describe --service-id 123 --version 1 --name logs"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + GetSplunkFn: getSplunkError, + }, + wantError: errTest.Error(), + }, + { + args: args("logging splunk describe --service-id 123 --version 1 --name logs"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + GetSplunkFn: getSplunkOK, + }, + wantOutput: describeSplunkOutput, + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertString(t, testcase.wantOutput, stdout.String()) + }) + } +} + +func TestSplunkUpdate(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging splunk update --service-id 123 --version 1 --new-name log"), + wantError: "error parsing arguments: required flag --name not provided", + }, + { + args: args("logging splunk update --service-id 123 --version 1 --name logs --new-name log --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + UpdateSplunkFn: updateSplunkError, + }, + wantError: errTest.Error(), + }, + { + args: args("logging splunk update --service-id 123 --version 1 --name logs --new-name log --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + UpdateSplunkFn: updateSplunkOK, + }, + wantOutput: "Updated Splunk logging endpoint log (service 123 version 4)", + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertStringContains(t, stdout.String(), testcase.wantOutput) + }) + } +} + +func TestSplunkDelete(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging splunk delete --service-id 123 --version 1"), + wantError: "error parsing arguments: required flag --name not provided", + }, + { + args: args("logging splunk delete --service-id 123 --version 1 --name logs --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + DeleteSplunkFn: deleteSplunkError, + }, + wantError: errTest.Error(), + }, + { + args: args("logging splunk delete --service-id 123 --version 1 --name logs --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + DeleteSplunkFn: deleteSplunkOK, + }, + wantOutput: "Deleted Splunk logging endpoint logs (service 123 version 4)", + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertStringContains(t, stdout.String(), testcase.wantOutput) + }) + } +} + +var errTest = errors.New("fixture error") + +func createSplunkOK(i *fastly.CreateSplunkInput) (*fastly.Splunk, error) { + return &fastly.Splunk{ + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: i.Name, + }, nil +} + +func createSplunkError(_ *fastly.CreateSplunkInput) (*fastly.Splunk, error) { + return nil, errTest +} + +func listSplunksOK(i *fastly.ListSplunksInput) ([]*fastly.Splunk, error) { + return []*fastly.Splunk{ + { + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("logs"), + URL: fastly.ToPointer("example.com"), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + FormatVersion: fastly.ToPointer(2), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + Placement: fastly.ToPointer("none"), + Token: fastly.ToPointer("tkn"), + TLSCACert: fastly.ToPointer("-----BEGIN CERTIFICATE-----foo"), + TLSHostname: fastly.ToPointer("example.com"), + TLSClientCert: fastly.ToPointer("-----BEGIN CERTIFICATE-----bar"), + TLSClientKey: fastly.ToPointer("-----BEGIN PRIVATE KEY-----bar"), + }, + { + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("analytics"), + URL: fastly.ToPointer("127.0.0.1"), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + FormatVersion: fastly.ToPointer(2), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + Placement: fastly.ToPointer("none"), + Token: fastly.ToPointer("tkn1"), + TLSCACert: fastly.ToPointer("-----BEGIN CERTIFICATE-----foo"), + TLSHostname: fastly.ToPointer("example.com"), + TLSClientCert: fastly.ToPointer("-----BEGIN CERTIFICATE-----qux"), + TLSClientKey: fastly.ToPointer("-----BEGIN PRIVATE KEY-----qux"), + }, + }, nil +} + +func listSplunksError(_ *fastly.ListSplunksInput) ([]*fastly.Splunk, error) { + return nil, errTest +} + +var listSplunksShortOutput = strings.TrimSpace(` +SERVICE VERSION NAME +123 1 logs +123 1 analytics +`) + "\n" + +var listSplunksVerboseOutput = strings.TrimSpace(` +Fastly API endpoint: https://api.fastly.com +Fastly API token provided via config file (profile: user) + +Service ID (via --service-id): 123 + +Version: 1 + Splunk 1/2 + Service ID: 123 + Version: 1 + Name: logs + URL: example.com + Token: tkn + TLS CA certificate: -----BEGIN CERTIFICATE-----foo + TLS hostname: example.com + TLS client certificate: -----BEGIN CERTIFICATE-----bar + TLS client key: -----BEGIN PRIVATE KEY-----bar + Format: %h %l %u %t "%r" %>s %b + Format version: 2 + Response condition: Prevent default logging + Placement: none + Splunk 2/2 + Service ID: 123 + Version: 1 + Name: analytics + URL: 127.0.0.1 + Token: tkn1 + TLS CA certificate: -----BEGIN CERTIFICATE-----foo + TLS hostname: example.com + TLS client certificate: -----BEGIN CERTIFICATE-----qux + TLS client key: -----BEGIN PRIVATE KEY-----qux + Format: %h %l %u %t "%r" %>s %b + Format version: 2 + Response condition: Prevent default logging + Placement: none +`) + "\n\n" + +func getSplunkOK(i *fastly.GetSplunkInput) (*fastly.Splunk, error) { + return &fastly.Splunk{ + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("logs"), + URL: fastly.ToPointer("example.com"), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + FormatVersion: fastly.ToPointer(2), + TLSCACert: fastly.ToPointer("-----BEGIN CERTIFICATE-----foo"), + TLSHostname: fastly.ToPointer("example.com"), + TLSClientCert: fastly.ToPointer("-----BEGIN CERTIFICATE-----bar"), + TLSClientKey: fastly.ToPointer("-----BEGIN PRIVATE KEY-----bar"), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + Placement: fastly.ToPointer("none"), + Token: fastly.ToPointer("tkn"), + }, nil +} + +func getSplunkError(_ *fastly.GetSplunkInput) (*fastly.Splunk, error) { + return nil, errTest +} + +var describeSplunkOutput = "\n" + strings.TrimSpace(` +Format: %h %l %u %t "%r" %>s %b +Format version: 2 +Name: logs +Placement: none +Response condition: Prevent default logging +Service ID: 123 +TLS CA certificate: -----BEGIN CERTIFICATE-----foo +TLS client certificate: -----BEGIN CERTIFICATE-----bar +TLS client key: -----BEGIN PRIVATE KEY-----bar +TLS hostname: example.com +Token: tkn +URL: example.com +Version: 1 +`) + "\n" + +func updateSplunkOK(i *fastly.UpdateSplunkInput) (*fastly.Splunk, error) { + return &fastly.Splunk{ + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("log"), + URL: fastly.ToPointer("example.com"), + Token: fastly.ToPointer("tkn"), + TLSCACert: fastly.ToPointer("-----BEGIN CERTIFICATE-----foo"), + TLSHostname: fastly.ToPointer("example.com"), + TLSClientCert: fastly.ToPointer("-----BEGIN CERTIFICATE-----bar"), + TLSClientKey: fastly.ToPointer("-----BEGIN PRIVATE KEY-----bar"), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + FormatVersion: fastly.ToPointer(2), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + Placement: fastly.ToPointer("none"), + }, nil +} + +func updateSplunkError(_ *fastly.UpdateSplunkInput) (*fastly.Splunk, error) { + return nil, errTest +} + +func deleteSplunkOK(_ *fastly.DeleteSplunkInput) error { + return nil +} + +func deleteSplunkError(_ *fastly.DeleteSplunkInput) error { + return errTest +} diff --git a/pkg/commands/logging/splunk/splunk_test.go b/pkg/commands/logging/splunk/splunk_test.go new file mode 100644 index 000000000..9fd35ed3c --- /dev/null +++ b/pkg/commands/logging/splunk/splunk_test.go @@ -0,0 +1,357 @@ +package splunk_test + +import ( + "bytes" + "testing" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/logging/splunk" + "github.com/fastly/cli/pkg/config" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" +) + +func TestCreateSplunkInput(t *testing.T) { + for _, testcase := range []struct { + name string + cmd *splunk.CreateCommand + want *fastly.CreateSplunkInput + wantError string + }{ + { + name: "required values set flag serviceID", + cmd: createCommandRequired(), + want: &fastly.CreateSplunkInput{ + ServiceID: "123", + ServiceVersion: 4, + Name: fastly.ToPointer("log"), + URL: fastly.ToPointer("example.com"), + }, + }, + { + name: "all values set flag serviceID", + cmd: createCommandAll(), + want: &fastly.CreateSplunkInput{ + ServiceID: "123", + ServiceVersion: 4, + Name: fastly.ToPointer("log"), + URL: fastly.ToPointer("example.com"), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + FormatVersion: fastly.ToPointer(2), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + Placement: fastly.ToPointer("none"), + Token: fastly.ToPointer("tkn"), + TLSCACert: fastly.ToPointer("-----BEGIN CERTIFICATE-----foo"), + TLSHostname: fastly.ToPointer("example.com"), + TLSClientCert: fastly.ToPointer("-----BEGIN CERTIFICATE-----bar"), + TLSClientKey: fastly.ToPointer("-----BEGIN PRIVATE KEY-----bar"), + }, + }, + { + name: "error missing serviceID", + cmd: createCommandMissingServiceID(), + want: nil, + wantError: errors.ErrNoServiceID.Error(), + }, + } { + t.Run(testcase.name, func(t *testing.T) { + var bs []byte + out := bytes.NewBuffer(bs) + verboseMode := true + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + AutoCloneFlag: testcase.cmd.AutoClone, + APIClient: testcase.cmd.Globals.APIClient, + Manifest: testcase.cmd.Manifest, + Out: out, + ServiceVersionFlag: testcase.cmd.ServiceVersion, + VerboseMode: verboseMode, + }) + + switch { + case err != nil && testcase.wantError == "": + t.Fatalf("unexpected error getting service details: %v", err) + return + case err != nil && testcase.wantError != "": + testutil.AssertErrorContains(t, err, testcase.wantError) + return + case err == nil && testcase.wantError != "": + t.Fatalf("expected error, have nil (service details: %s, %d)", serviceID, serviceVersion.Number) + case err == nil && testcase.wantError == "": + have, err := testcase.cmd.ConstructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertEqual(t, testcase.want, have) + } + }) + } +} + +func TestUpdateSplunkInput(t *testing.T) { + scenarios := []struct { + name string + cmd *splunk.UpdateCommand + api mock.API + want *fastly.UpdateSplunkInput + wantError string + }{ + { + name: "no updates", + cmd: updateCommandNoUpdates(), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + GetSplunkFn: getSplunkOK, + }, + want: &fastly.UpdateSplunkInput{ + ServiceID: "123", + ServiceVersion: 4, + Name: "log", + }, + }, + { + name: "all values set flag serviceID", + cmd: updateCommandAll(), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + GetSplunkFn: getSplunkOK, + }, + want: &fastly.UpdateSplunkInput{ + ServiceID: "123", + ServiceVersion: 4, + Name: "log", + NewName: fastly.ToPointer("new1"), + URL: fastly.ToPointer("new2"), + Format: fastly.ToPointer("new3"), + FormatVersion: fastly.ToPointer(3), + ResponseCondition: fastly.ToPointer("new4"), + Placement: fastly.ToPointer("new5"), + Token: fastly.ToPointer("new6"), + TLSCACert: fastly.ToPointer("new7"), + TLSHostname: fastly.ToPointer("new8"), + TLSClientCert: fastly.ToPointer("new9"), + TLSClientKey: fastly.ToPointer("new10"), + }, + }, + { + name: "error missing serviceID", + cmd: updateCommandMissingServiceID(), + want: nil, + wantError: errors.ErrNoServiceID.Error(), + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(testcase.name, func(t *testing.T) { + testcase.cmd.Globals.APIClient = testcase.api + + var bs []byte + out := bytes.NewBuffer(bs) + verboseMode := true + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + AutoCloneFlag: testcase.cmd.AutoClone, + APIClient: testcase.api, + Manifest: testcase.cmd.Manifest, + Out: out, + ServiceVersionFlag: testcase.cmd.ServiceVersion, + VerboseMode: verboseMode, + }) + + switch { + case err != nil && testcase.wantError == "": + t.Fatalf("unexpected error getting service details: %v", err) + return + case err != nil && testcase.wantError != "": + testutil.AssertErrorContains(t, err, testcase.wantError) + return + case err == nil && testcase.wantError != "": + t.Fatalf("expected error, have nil (service details: %s, %d)", serviceID, serviceVersion.Number) + case err == nil && testcase.wantError == "": + have, err := testcase.cmd.ConstructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertEqual(t, testcase.want, have) + } + }) + } +} + +func createCommandRequired() *splunk.CreateCommand { + var b bytes.Buffer + + g := global.Data{ + Config: config.File{}, + Env: config.Environment{}, + Output: &b, + } + g.APIClient, _ = mock.APIClient(mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + })("token", "endpoint", false) + + return &splunk.CreateCommand{ + Base: argparser.Base{ + Globals: &g, + }, + Manifest: manifest.Data{ + Flag: manifest.Flag{ + ServiceID: "123", + }, + }, + ServiceVersion: argparser.OptionalServiceVersion{ + OptionalString: argparser.OptionalString{Value: "1"}, + }, + AutoClone: argparser.OptionalAutoClone{ + OptionalBool: argparser.OptionalBool{ + Optional: argparser.Optional{ + WasSet: true, + }, + Value: true, + }, + }, + EndpointName: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "log"}, + URL: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "example.com"}, + } +} + +func createCommandAll() *splunk.CreateCommand { + var b bytes.Buffer + + g := global.Data{ + Config: config.File{}, + Env: config.Environment{}, + Output: &b, + } + g.APIClient, _ = mock.APIClient(mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + })("token", "endpoint", false) + + return &splunk.CreateCommand{ + Base: argparser.Base{ + Globals: &g, + }, + Manifest: manifest.Data{ + Flag: manifest.Flag{ + ServiceID: "123", + }, + }, + ServiceVersion: argparser.OptionalServiceVersion{ + OptionalString: argparser.OptionalString{Value: "1"}, + }, + AutoClone: argparser.OptionalAutoClone{ + OptionalBool: argparser.OptionalBool{ + Optional: argparser.Optional{ + WasSet: true, + }, + Value: true, + }, + }, + EndpointName: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "log"}, + URL: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "example.com"}, + Format: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: `%h %l %u %t "%r" %>s %b`}, + FormatVersion: argparser.OptionalInt{Optional: argparser.Optional{WasSet: true}, Value: 2}, + TimestampFormat: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "%Y-%m-%dT%H:%M:%S.000"}, + ResponseCondition: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "Prevent default logging"}, + Placement: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "none"}, + Token: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "tkn"}, + TLSCACert: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "-----BEGIN CERTIFICATE-----foo"}, + TLSHostname: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "example.com"}, + TLSClientCert: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "-----BEGIN CERTIFICATE-----bar"}, + TLSClientKey: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "-----BEGIN PRIVATE KEY-----bar"}, + } +} + +func createCommandMissingServiceID() *splunk.CreateCommand { + res := createCommandAll() + res.Manifest = manifest.Data{} + return res +} + +func updateCommandNoUpdates() *splunk.UpdateCommand { + var b bytes.Buffer + + g := global.Data{ + Config: config.File{}, + Env: config.Environment{}, + Output: &b, + } + + return &splunk.UpdateCommand{ + Base: argparser.Base{ + Globals: &g, + }, + Manifest: manifest.Data{ + Flag: manifest.Flag{ + ServiceID: "123", + }, + }, + EndpointName: "log", + ServiceVersion: argparser.OptionalServiceVersion{ + OptionalString: argparser.OptionalString{Value: "1"}, + }, + AutoClone: argparser.OptionalAutoClone{ + OptionalBool: argparser.OptionalBool{ + Optional: argparser.Optional{ + WasSet: true, + }, + Value: true, + }, + }, + } +} + +func updateCommandAll() *splunk.UpdateCommand { + var b bytes.Buffer + + g := global.Data{ + Config: config.File{}, + Env: config.Environment{}, + Output: &b, + } + + return &splunk.UpdateCommand{ + Base: argparser.Base{ + Globals: &g, + }, + Manifest: manifest.Data{ + Flag: manifest.Flag{ + ServiceID: "123", + }, + }, + EndpointName: "log", + ServiceVersion: argparser.OptionalServiceVersion{ + OptionalString: argparser.OptionalString{Value: "1"}, + }, + AutoClone: argparser.OptionalAutoClone{ + OptionalBool: argparser.OptionalBool{ + Optional: argparser.Optional{ + WasSet: true, + }, + Value: true, + }, + }, + NewName: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new1"}, + URL: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new2"}, + Format: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new3"}, + FormatVersion: argparser.OptionalInt{Optional: argparser.Optional{WasSet: true}, Value: 3}, + ResponseCondition: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new4"}, + Placement: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new5"}, + Token: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new6"}, + TLSCACert: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new7"}, + TLSHostname: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new8"}, + TLSClientCert: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new9"}, + TLSClientKey: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new10"}, + } +} + +func updateCommandMissingServiceID() *splunk.UpdateCommand { + res := updateCommandAll() + res.Manifest = manifest.Data{} + return res +} diff --git a/pkg/commands/logging/splunk/update.go b/pkg/commands/logging/splunk/update.go new file mode 100644 index 000000000..fd4dfe61f --- /dev/null +++ b/pkg/commands/logging/splunk/update.go @@ -0,0 +1,188 @@ +package splunk + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/logging/common" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/text" +) + +// UpdateCommand calls the Fastly API to update a Splunk logging endpoint. +type UpdateCommand struct { + argparser.Base + Manifest manifest.Data + + // Required. + EndpointName string // Can't shadow argparser.Base method Name(). + ServiceName argparser.OptionalServiceNameID + ServiceVersion argparser.OptionalServiceVersion + + // Optional. + AutoClone argparser.OptionalAutoClone + NewName argparser.OptionalString + URL argparser.OptionalString + Format argparser.OptionalString + FormatVersion argparser.OptionalInt + ResponseCondition argparser.OptionalString + Placement argparser.OptionalString + Token argparser.OptionalString + TLSCACert argparser.OptionalString + TLSHostname argparser.OptionalString + TLSClientCert argparser.OptionalString + TLSClientKey argparser.OptionalString +} + +// NewUpdateCommand returns a usable command registered under the parent. +func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { + c := UpdateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("update", "Update a Splunk logging endpoint on a Fastly service version") + + // Required. + c.CmdClause.Flag("name", "The name of the Splunk logging object").Short('n').Required().StringVar(&c.EndpointName) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.ServiceVersion.Value, + Required: true, + }) + + // Optional. + c.CmdClause.Flag("auth-token", "").Action(c.Token.Set).StringVar(&c.Token.Value) + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.AutoClone.Set, + Dst: &c.AutoClone.Value, + }) + c.CmdClause.Flag("new-name", "New name of the Splunk logging object").Action(c.NewName.Set).StringVar(&c.NewName.Value) + common.Format(c.CmdClause, &c.Format) + common.FormatVersion(c.CmdClause, &c.FormatVersion) + c.CmdClause.Flag("placement", " Where in the generated VCL the logging call should be placed, overriding any format_version default. Can be none or waf_debug. This field is not required and has no default value").Action(c.Placement.Set).StringVar(&c.Placement.Value) + common.ResponseCondition(c.CmdClause, &c.ResponseCondition) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.ServiceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.ServiceName.Value, + }) + common.TLSCACert(c.CmdClause, &c.TLSCACert) + common.TLSClientCert(c.CmdClause, &c.TLSClientCert) + common.TLSClientKey(c.CmdClause, &c.TLSClientKey) + common.TLSHostname(c.CmdClause, &c.TLSHostname) + c.CmdClause.Flag("url", "The URL to POST to.").Action(c.URL.Set).StringVar(&c.URL.Value) + return &c +} + +// ConstructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *UpdateCommand) ConstructInput(serviceID string, serviceVersion int) (*fastly.UpdateSplunkInput, error) { + input := fastly.UpdateSplunkInput{ + ServiceID: serviceID, + ServiceVersion: serviceVersion, + Name: c.EndpointName, + } + + // Set new values if set by user. + if c.NewName.WasSet { + input.NewName = &c.NewName.Value + } + + if c.URL.WasSet { + input.URL = &c.URL.Value + } + + if c.Format.WasSet { + input.Format = fastly.ToPointer(argparser.Content(c.Format.Value)) + } + + if c.FormatVersion.WasSet { + input.FormatVersion = &c.FormatVersion.Value + } + + if c.ResponseCondition.WasSet { + input.ResponseCondition = &c.ResponseCondition.Value + } + + if c.Placement.WasSet { + input.Placement = &c.Placement.Value + } + + if c.Token.WasSet { + input.Token = &c.Token.Value + } + + if c.TLSCACert.WasSet { + input.TLSCACert = &c.TLSCACert.Value + } + + if c.TLSHostname.WasSet { + input.TLSHostname = &c.TLSHostname.Value + } + + if c.TLSClientCert.WasSet { + input.TLSClientCert = &c.TLSClientCert.Value + } + + if c.TLSClientKey.WasSet { + input.TLSClientKey = &c.TLSClientKey.Value + } + + return &input, nil +} + +// Exec invokes the application logic for the command. +func (c *UpdateCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.AutoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.ServiceName, + ServiceVersionFlag: c.ServiceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + input, err := c.ConstructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + splunk, err := c.Globals.APIClient.UpdateSplunk(input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + text.Success(out, + "Updated Splunk logging endpoint %s (service %s version %d)", + fastly.ToValue(splunk.Name), + fastly.ToValue(splunk.ServiceID), + fastly.ToValue(splunk.ServiceVersion), + ) + return nil +} diff --git a/pkg/commands/logging/sumologic/create.go b/pkg/commands/logging/sumologic/create.go new file mode 100644 index 000000000..c6ac70543 --- /dev/null +++ b/pkg/commands/logging/sumologic/create.go @@ -0,0 +1,158 @@ +package sumologic + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/logging/common" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/text" +) + +// CreateCommand calls the Fastly API to create a Sumologic logging endpoint. +type CreateCommand struct { + argparser.Base + Manifest manifest.Data + + // Required. + ServiceName argparser.OptionalServiceNameID + ServiceVersion argparser.OptionalServiceVersion + + // Optional. + AutoClone argparser.OptionalAutoClone + EndpointName argparser.OptionalString // Can't shadow argparser.Base method Name(). + Format argparser.OptionalString + FormatVersion argparser.OptionalInt + MessageType argparser.OptionalString + Placement argparser.OptionalString + ResponseCondition argparser.OptionalString + URL argparser.OptionalString +} + +// NewCreateCommand returns a usable command registered under the parent. +func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { + c := CreateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("create", "Create a Sumologic logging endpoint on a Fastly service version").Alias("add") + + // Required. + c.CmdClause.Flag("name", "The name of the Sumologic logging object. Used as a primary key for API access").Short('n').Action(c.EndpointName.Set).StringVar(&c.EndpointName.Value) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.ServiceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.AutoClone.Set, + Dst: &c.AutoClone.Value, + }) + c.CmdClause.Flag("format-version", "The version of the custom logging format used for the configured endpoint. Can be either 2 (the default, version 2 log format) or 1 (the version 1 log format). The logging call gets placed by default in vcl_log if format_version is set to 2 and in vcl_deliver if format_version is set to 1").Action(c.FormatVersion.Set).IntVar(&c.FormatVersion.Value) + common.Format(c.CmdClause, &c.Format) + common.MessageType(c.CmdClause, &c.MessageType) + common.Placement(c.CmdClause, &c.Placement) + common.ResponseCondition(c.CmdClause, &c.ResponseCondition) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.ServiceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.ServiceName.Value, + }) + c.CmdClause.Flag("url", "The URL to POST to").Action(c.URL.Set).StringVar(&c.URL.Value) + return &c +} + +// ConstructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *CreateCommand) ConstructInput(serviceID string, serviceVersion int) (*fastly.CreateSumologicInput, error) { + var input fastly.CreateSumologicInput + + input.ServiceID = serviceID + input.ServiceVersion = serviceVersion + if c.EndpointName.WasSet { + input.Name = &c.EndpointName.Value + } + if c.URL.WasSet { + input.URL = &c.URL.Value + } + + if c.Format.WasSet { + input.Format = fastly.ToPointer(argparser.Content(c.Format.Value)) + } + + if c.FormatVersion.WasSet { + input.FormatVersion = &c.FormatVersion.Value + } + + if c.ResponseCondition.WasSet { + input.ResponseCondition = &c.ResponseCondition.Value + } + + if c.Placement.WasSet { + input.Placement = &c.Placement.Value + } + + if c.MessageType.WasSet { + input.MessageType = &c.MessageType.Value + } + + return &input, nil +} + +// Exec invokes the application logic for the command. +func (c *CreateCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.AutoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.ServiceName, + ServiceVersionFlag: c.ServiceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + input, err := c.ConstructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + d, err := c.Globals.APIClient.CreateSumologic(input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + text.Success(out, + "Created Sumologic logging endpoint %s (service %s version %d)", + fastly.ToValue(d.Name), + fastly.ToValue(d.ServiceID), + fastly.ToValue(d.ServiceVersion), + ) + return nil +} diff --git a/pkg/commands/logging/sumologic/delete.go b/pkg/commands/logging/sumologic/delete.go new file mode 100644 index 000000000..1d47cc686 --- /dev/null +++ b/pkg/commands/logging/sumologic/delete.go @@ -0,0 +1,94 @@ +package sumologic + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// DeleteCommand calls the Fastly API to delete a Sumologic logging endpoint. +type DeleteCommand struct { + argparser.Base + Input fastly.DeleteSumologicInput + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion + autoClone argparser.OptionalAutoClone +} + +// NewDeleteCommand returns a usable command registered under the parent. +func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { + c := DeleteCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("delete", "Delete a Sumologic logging endpoint on a Fastly service version").Alias("remove") + + // Required. + c.CmdClause.Flag("name", "The name of the Sumologic logging object").Short('n').Required().StringVar(&c.Input.Name) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.autoClone.Set, + Dst: &c.autoClone.Value, + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + return &c +} + +// Exec invokes the application logic for the command. +func (c *DeleteCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.autoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + c.Input.ServiceID = serviceID + c.Input.ServiceVersion = fastly.ToValue(serviceVersion.Number) + + if err := c.Globals.APIClient.DeleteSumologic(&c.Input); err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + text.Success(out, "Deleted Sumologic logging endpoint %s (service %s version %d)", c.Input.Name, c.Input.ServiceID, c.Input.ServiceVersion) + return nil +} diff --git a/pkg/commands/logging/sumologic/describe.go b/pkg/commands/logging/sumologic/describe.go new file mode 100644 index 000000000..5d2aa9da3 --- /dev/null +++ b/pkg/commands/logging/sumologic/describe.go @@ -0,0 +1,110 @@ +package sumologic + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// DescribeCommand calls the Fastly API to describe a Sumologic logging endpoint. +type DescribeCommand struct { + argparser.Base + argparser.JSONOutput + + Input fastly.GetSumologicInput + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion +} + +// NewDescribeCommand returns a usable command registered under the parent. +func NewDescribeCommand(parent argparser.Registerer, g *global.Data) *DescribeCommand { + c := DescribeCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("describe", "Show detailed information about a Sumologic logging endpoint on a Fastly service version").Alias("get") + + // Required. + c.CmdClause.Flag("name", "The name of the Sumologic logging object").Short('n').Required().StringVar(&c.Input.Name) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + return &c +} + +// Exec invokes the application logic for the command. +func (c *DescribeCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fsterr.ServiceVersion(serviceVersion), + }) + return err + } + + c.Input.ServiceID = serviceID + c.Input.ServiceVersion = fastly.ToValue(serviceVersion.Number) + + o, err := c.Globals.APIClient.GetSumologic(&c.Input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + lines := text.Lines{ + "Format version": fastly.ToValue(o.FormatVersion), + "Format": fastly.ToValue(o.Format), + "Message type": fastly.ToValue(o.MessageType), + "Name": fastly.ToValue(o.Name), + "Placement": fastly.ToValue(o.Placement), + "Response condition": fastly.ToValue(o.ResponseCondition), + "URL": fastly.ToValue(o.URL), + "Version": fastly.ToValue(o.ServiceVersion), + } + if !c.Globals.Verbose() { + lines["Service ID"] = fastly.ToValue(o.ServiceID) + } + text.PrintLines(out, lines) + + return nil +} diff --git a/pkg/logging/sumologic/doc.go b/pkg/commands/logging/sumologic/doc.go similarity index 100% rename from pkg/logging/sumologic/doc.go rename to pkg/commands/logging/sumologic/doc.go diff --git a/pkg/commands/logging/sumologic/list.go b/pkg/commands/logging/sumologic/list.go new file mode 100644 index 000000000..78df125e5 --- /dev/null +++ b/pkg/commands/logging/sumologic/list.go @@ -0,0 +1,124 @@ +package sumologic + +import ( + "fmt" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// ListCommand calls the Fastly API to list Sumologic logging endpoints. +type ListCommand struct { + argparser.Base + argparser.JSONOutput + + Input fastly.ListSumologicsInput + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion +} + +// NewListCommand returns a usable command registered under the parent. +func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { + c := ListCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("list", "List Sumologic endpoints on a Fastly service version") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + return &c +} + +// Exec invokes the application logic for the command. +func (c *ListCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fsterr.ServiceVersion(serviceVersion), + }) + return err + } + + c.Input.ServiceID = serviceID + c.Input.ServiceVersion = fastly.ToValue(serviceVersion.Number) + + o, err := c.Globals.APIClient.ListSumologics(&c.Input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + if !c.Globals.Verbose() { + tw := text.NewTable(out) + tw.AddHeader("SERVICE", "VERSION", "NAME") + for _, sumologic := range o { + tw.AddLine( + fastly.ToValue(sumologic.ServiceID), + fastly.ToValue(sumologic.ServiceVersion), + fastly.ToValue(sumologic.Name), + ) + } + tw.Print() + return nil + } + + fmt.Fprintf(out, "Version: %d\n", c.Input.ServiceVersion) + for i, sumologic := range o { + fmt.Fprintf(out, "\tSumologic %d/%d\n", i+1, len(o)) + fmt.Fprintf(out, "\t\tService ID: %s\n", fastly.ToValue(sumologic.ServiceID)) + fmt.Fprintf(out, "\t\tVersion: %d\n", fastly.ToValue(sumologic.ServiceVersion)) + fmt.Fprintf(out, "\t\tName: %s\n", fastly.ToValue(sumologic.Name)) + fmt.Fprintf(out, "\t\tURL: %s\n", fastly.ToValue(sumologic.URL)) + fmt.Fprintf(out, "\t\tFormat: %s\n", fastly.ToValue(sumologic.Format)) + fmt.Fprintf(out, "\t\tFormat version: %d\n", fastly.ToValue(sumologic.FormatVersion)) + fmt.Fprintf(out, "\t\tResponse condition: %s\n", fastly.ToValue(sumologic.ResponseCondition)) + fmt.Fprintf(out, "\t\tMessage type: %s\n", fastly.ToValue(sumologic.MessageType)) + fmt.Fprintf(out, "\t\tPlacement: %s\n", fastly.ToValue(sumologic.Placement)) + } + fmt.Fprintln(out) + + return nil +} diff --git a/pkg/commands/logging/sumologic/root.go b/pkg/commands/logging/sumologic/root.go new file mode 100644 index 000000000..8dba083d2 --- /dev/null +++ b/pkg/commands/logging/sumologic/root.go @@ -0,0 +1,31 @@ +package sumologic + +import ( + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" +) + +// RootCommand is the parent command for all subcommands in this package. +// It should be installed under the primary root command. +type RootCommand struct { + argparser.Base + // no flags +} + +// CommandName is the string to be used to invoke this command. +const CommandName = "sumologic" + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent argparser.Registerer, g *global.Data) *RootCommand { + var c RootCommand + c.Globals = g + c.CmdClause = parent.Command(CommandName, "Manipulate Fastly service version Sumologic logging endpoints") + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { + panic("unreachable") +} diff --git a/pkg/commands/logging/sumologic/sumologic_integration_test.go b/pkg/commands/logging/sumologic/sumologic_integration_test.go new file mode 100644 index 000000000..c6fed11e8 --- /dev/null +++ b/pkg/commands/logging/sumologic/sumologic_integration_test.go @@ -0,0 +1,407 @@ +package sumologic_test + +import ( + "bytes" + "errors" + "io" + "strings" + "testing" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/app" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" +) + +func TestSumologicCreate(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging sumologic create --service-id 123 --version 1 --name log --url example.com --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + CreateSumologicFn: createSumologicOK, + }, + wantOutput: "Created Sumologic logging endpoint log (service 123 version 4)", + }, + { + args: args("logging sumologic create --service-id 123 --version 1 --name log --url example.com --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + CreateSumologicFn: createSumologicError, + }, + wantError: errTest.Error(), + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertStringContains(t, stdout.String(), testcase.wantOutput) + }) + } +} + +func TestSumologicList(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging sumologic list --service-id 123 --version 1"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListSumologicsFn: listSumologicsOK, + }, + wantOutput: listSumologicsShortOutput, + }, + { + args: args("logging sumologic list --service-id 123 --version 1 --verbose"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListSumologicsFn: listSumologicsOK, + }, + wantOutput: listSumologicsVerboseOutput, + }, + { + args: args("logging sumologic list --service-id 123 --version 1 -v"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListSumologicsFn: listSumologicsOK, + }, + wantOutput: listSumologicsVerboseOutput, + }, + { + args: args("logging sumologic --verbose list --service-id 123 --version 1"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListSumologicsFn: listSumologicsOK, + }, + wantOutput: listSumologicsVerboseOutput, + }, + { + args: args("logging -v sumologic list --service-id 123 --version 1"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListSumologicsFn: listSumologicsOK, + }, + wantOutput: listSumologicsVerboseOutput, + }, + { + args: args("logging sumologic list --service-id 123 --version 1"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListSumologicsFn: listSumologicsError, + }, + wantError: errTest.Error(), + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertString(t, testcase.wantOutput, stdout.String()) + }) + } +} + +func TestSumologicDescribe(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging sumologic describe --service-id 123 --version 1"), + wantError: "error parsing arguments: required flag --name not provided", + }, + { + args: args("logging sumologic describe --service-id 123 --version 1 --name logs"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + GetSumologicFn: getSumologicError, + }, + wantError: errTest.Error(), + }, + { + args: args("logging sumologic describe --service-id 123 --version 1 --name logs"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + GetSumologicFn: getSumologicOK, + }, + wantOutput: describeSumologicOutput, + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertString(t, testcase.wantOutput, stdout.String()) + }) + } +} + +func TestSumologicUpdate(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging sumologic update --service-id 123 --version 1 --new-name log"), + wantError: "error parsing arguments: required flag --name not provided", + }, + { + args: args("logging sumologic update --service-id 123 --version 1 --name logs --new-name log --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + UpdateSumologicFn: updateSumologicError, + }, + wantError: errTest.Error(), + }, + { + args: args("logging sumologic update --service-id 123 --version 1 --name logs --new-name log --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + UpdateSumologicFn: updateSumologicOK, + }, + wantOutput: "Updated Sumologic logging endpoint log (service 123 version 4)", + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertStringContains(t, stdout.String(), testcase.wantOutput) + }) + } +} + +func TestSumologicDelete(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging sumologic delete --service-id 123 --version 1"), + wantError: "error parsing arguments: required flag --name not provided", + }, + { + args: args("logging sumologic delete --service-id 123 --version 1 --name logs --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + DeleteSumologicFn: deleteSumologicError, + }, + wantError: errTest.Error(), + }, + { + args: args("logging sumologic delete --service-id 123 --version 1 --name logs --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + DeleteSumologicFn: deleteSumologicOK, + }, + wantOutput: "Deleted Sumologic logging endpoint logs (service 123 version 4)", + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertStringContains(t, stdout.String(), testcase.wantOutput) + }) + } +} + +var errTest = errors.New("fixture error") + +func createSumologicOK(i *fastly.CreateSumologicInput) (*fastly.Sumologic, error) { + return &fastly.Sumologic{ + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: i.Name, + }, nil +} + +func createSumologicError(_ *fastly.CreateSumologicInput) (*fastly.Sumologic, error) { + return nil, errTest +} + +func listSumologicsOK(i *fastly.ListSumologicsInput) ([]*fastly.Sumologic, error) { + return []*fastly.Sumologic{ + { + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("logs"), + URL: fastly.ToPointer("example.com"), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + FormatVersion: fastly.ToPointer(2), + MessageType: fastly.ToPointer("classic"), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + Placement: fastly.ToPointer("none"), + }, + { + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("analytics"), + URL: fastly.ToPointer("bar.com"), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + MessageType: fastly.ToPointer("classic"), + FormatVersion: fastly.ToPointer(2), + Placement: fastly.ToPointer("none"), + }, + }, nil +} + +func listSumologicsError(_ *fastly.ListSumologicsInput) ([]*fastly.Sumologic, error) { + return nil, errTest +} + +var listSumologicsShortOutput = strings.TrimSpace(` +SERVICE VERSION NAME +123 1 logs +123 1 analytics +`) + "\n" + +var listSumologicsVerboseOutput = strings.TrimSpace(` +Fastly API endpoint: https://api.fastly.com +Fastly API token provided via config file (profile: user) + +Service ID (via --service-id): 123 + +Version: 1 + Sumologic 1/2 + Service ID: 123 + Version: 1 + Name: logs + URL: example.com + Format: %h %l %u %t "%r" %>s %b + Format version: 2 + Response condition: Prevent default logging + Message type: classic + Placement: none + Sumologic 2/2 + Service ID: 123 + Version: 1 + Name: analytics + URL: bar.com + Format: %h %l %u %t "%r" %>s %b + Format version: 2 + Response condition: Prevent default logging + Message type: classic + Placement: none +`) + "\n\n" + +func getSumologicOK(i *fastly.GetSumologicInput) (*fastly.Sumologic, error) { + return &fastly.Sumologic{ + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("logs"), + URL: fastly.ToPointer("example.com"), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + FormatVersion: fastly.ToPointer(2), + MessageType: fastly.ToPointer("classic"), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + Placement: fastly.ToPointer("none"), + }, nil +} + +func getSumologicError(_ *fastly.GetSumologicInput) (*fastly.Sumologic, error) { + return nil, errTest +} + +var describeSumologicOutput = "\n" + strings.TrimSpace(` +Format: %h %l %u %t "%r" %>s %b +Format version: 2 +Message type: classic +Name: logs +Placement: none +Response condition: Prevent default logging +Service ID: 123 +URL: example.com +Version: 1 +`) + "\n" + +func updateSumologicOK(i *fastly.UpdateSumologicInput) (*fastly.Sumologic, error) { + return &fastly.Sumologic{ + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("log"), + URL: fastly.ToPointer("example.com"), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + FormatVersion: fastly.ToPointer(2), + MessageType: fastly.ToPointer("classic"), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + Placement: fastly.ToPointer("none"), + }, nil +} + +func updateSumologicError(_ *fastly.UpdateSumologicInput) (*fastly.Sumologic, error) { + return nil, errTest +} + +func deleteSumologicOK(_ *fastly.DeleteSumologicInput) error { + return nil +} + +func deleteSumologicError(_ *fastly.DeleteSumologicInput) error { + return errTest +} diff --git a/pkg/commands/logging/sumologic/sumologic_test.go b/pkg/commands/logging/sumologic/sumologic_test.go new file mode 100644 index 000000000..44657b28c --- /dev/null +++ b/pkg/commands/logging/sumologic/sumologic_test.go @@ -0,0 +1,340 @@ +package sumologic_test + +import ( + "bytes" + "testing" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/logging/sumologic" + "github.com/fastly/cli/pkg/config" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" +) + +func TestCreateSumologicInput(t *testing.T) { + for _, testcase := range []struct { + name string + cmd *sumologic.CreateCommand + want *fastly.CreateSumologicInput + wantError string + }{ + { + name: "required values set flag serviceID", + cmd: createCommandRequired(), + want: &fastly.CreateSumologicInput{ + ServiceID: "123", + ServiceVersion: 4, + Name: fastly.ToPointer("log"), + URL: fastly.ToPointer("example.com"), + }, + }, + { + name: "all values set flag serviceID", + cmd: createCommandOK(), + want: &fastly.CreateSumologicInput{ + ServiceID: "123", + ServiceVersion: 4, + Name: fastly.ToPointer("log"), + URL: fastly.ToPointer("example.com"), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + FormatVersion: fastly.ToPointer(2), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + Placement: fastly.ToPointer("none"), + MessageType: fastly.ToPointer("classic"), + }, + }, + { + name: "error missing serviceID", + cmd: createCommandMissingServiceID(), + want: nil, + wantError: errors.ErrNoServiceID.Error(), + }, + } { + t.Run(testcase.name, func(t *testing.T) { + var bs []byte + out := bytes.NewBuffer(bs) + verboseMode := true + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + AutoCloneFlag: testcase.cmd.AutoClone, + APIClient: testcase.cmd.Globals.APIClient, + Manifest: testcase.cmd.Manifest, + Out: out, + ServiceVersionFlag: testcase.cmd.ServiceVersion, + VerboseMode: verboseMode, + }) + + switch { + case err != nil && testcase.wantError == "": + t.Fatalf("unexpected error getting service details: %v", err) + return + case err != nil && testcase.wantError != "": + testutil.AssertErrorContains(t, err, testcase.wantError) + return + case err == nil && testcase.wantError != "": + t.Fatalf("expected error, have nil (service details: %s, %d)", serviceID, serviceVersion.Number) + case err == nil && testcase.wantError == "": + have, err := testcase.cmd.ConstructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertEqual(t, testcase.want, have) + } + }) + } +} + +func TestUpdateSumologicInput(t *testing.T) { + scenarios := []struct { + name string + cmd *sumologic.UpdateCommand + api mock.API + want *fastly.UpdateSumologicInput + wantError string + }{ + { + name: "no updates", + cmd: updateCommandNoUpdates(), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + GetSumologicFn: getSumologicOK, + }, + want: &fastly.UpdateSumologicInput{ + ServiceID: "123", + ServiceVersion: 4, + Name: "log", + }, + }, + { + name: "all values set flag serviceID", + cmd: updateCommandAll(), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + GetSumologicFn: getSumologicOK, + }, + want: &fastly.UpdateSumologicInput{ + ServiceID: "123", + ServiceVersion: 4, + Name: "log", + NewName: fastly.ToPointer("new1"), + URL: fastly.ToPointer("new2"), + Format: fastly.ToPointer("new3"), + FormatVersion: fastly.ToPointer(3), + ResponseCondition: fastly.ToPointer("new4"), + Placement: fastly.ToPointer("new5"), + MessageType: fastly.ToPointer("new6"), + }, + }, + { + name: "error missing serviceID", + cmd: updateCommandMissingServiceID(), + want: nil, + wantError: errors.ErrNoServiceID.Error(), + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(testcase.name, func(t *testing.T) { + testcase.cmd.Globals.APIClient = testcase.api + + var bs []byte + out := bytes.NewBuffer(bs) + verboseMode := true + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + AutoCloneFlag: testcase.cmd.AutoClone, + APIClient: testcase.api, + Manifest: testcase.cmd.Manifest, + Out: out, + ServiceVersionFlag: testcase.cmd.ServiceVersion, + VerboseMode: verboseMode, + }) + + switch { + case err != nil && testcase.wantError == "": + t.Fatalf("unexpected error getting service details: %v", err) + return + case err != nil && testcase.wantError != "": + testutil.AssertErrorContains(t, err, testcase.wantError) + return + case err == nil && testcase.wantError != "": + t.Fatalf("expected error, have nil (service details: %s, %d)", serviceID, serviceVersion.Number) + case err == nil && testcase.wantError == "": + have, err := testcase.cmd.ConstructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertEqual(t, testcase.want, have) + } + }) + } +} + +func createCommandOK() *sumologic.CreateCommand { + var b bytes.Buffer + + g := global.Data{ + Config: config.File{}, + Env: config.Environment{}, + Output: &b, + } + g.APIClient, _ = mock.APIClient(mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + })("token", "endpoint", false) + + return &sumologic.CreateCommand{ + Base: argparser.Base{ + Globals: &g, + }, + Manifest: manifest.Data{ + Flag: manifest.Flag{ + ServiceID: "123", + }, + }, + EndpointName: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "log"}, + URL: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "example.com"}, + ServiceVersion: argparser.OptionalServiceVersion{ + OptionalString: argparser.OptionalString{Value: "1"}, + }, + AutoClone: argparser.OptionalAutoClone{ + OptionalBool: argparser.OptionalBool{ + Optional: argparser.Optional{ + WasSet: true, + }, + Value: true, + }, + }, + Format: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: `%h %l %u %t "%r" %>s %b`}, + FormatVersion: argparser.OptionalInt{Optional: argparser.Optional{WasSet: true}, Value: 2}, + ResponseCondition: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "Prevent default logging"}, + Placement: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "none"}, + MessageType: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "classic"}, + } +} + +func createCommandRequired() *sumologic.CreateCommand { + var b bytes.Buffer + + g := global.Data{ + Config: config.File{}, + Env: config.Environment{}, + Output: &b, + } + g.APIClient, _ = mock.APIClient(mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + })("token", "endpoint", false) + + return &sumologic.CreateCommand{ + Base: argparser.Base{ + Globals: &g, + }, + Manifest: manifest.Data{ + Flag: manifest.Flag{ + ServiceID: "123", + }, + }, + EndpointName: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "log"}, + URL: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "example.com"}, + ServiceVersion: argparser.OptionalServiceVersion{ + OptionalString: argparser.OptionalString{Value: "1"}, + }, + AutoClone: argparser.OptionalAutoClone{ + OptionalBool: argparser.OptionalBool{ + Optional: argparser.Optional{ + WasSet: true, + }, + Value: true, + }, + }, + } +} + +func createCommandMissingServiceID() *sumologic.CreateCommand { + res := createCommandOK() + res.Manifest = manifest.Data{} + return res +} + +func updateCommandNoUpdates() *sumologic.UpdateCommand { + var b bytes.Buffer + + g := global.Data{ + Config: config.File{}, + Env: config.Environment{}, + Output: &b, + } + + return &sumologic.UpdateCommand{ + Base: argparser.Base{ + Globals: &g, + }, + Manifest: manifest.Data{ + Flag: manifest.Flag{ + ServiceID: "123", + }, + }, + EndpointName: "log", + ServiceVersion: argparser.OptionalServiceVersion{ + OptionalString: argparser.OptionalString{Value: "1"}, + }, + AutoClone: argparser.OptionalAutoClone{ + OptionalBool: argparser.OptionalBool{ + Optional: argparser.Optional{ + WasSet: true, + }, + Value: true, + }, + }, + } +} + +func updateCommandAll() *sumologic.UpdateCommand { + var b bytes.Buffer + + g := global.Data{ + Config: config.File{}, + Env: config.Environment{}, + Output: &b, + } + + return &sumologic.UpdateCommand{ + Base: argparser.Base{ + Globals: &g, + }, + Manifest: manifest.Data{ + Flag: manifest.Flag{ + ServiceID: "123", + }, + }, + EndpointName: "log", + ServiceVersion: argparser.OptionalServiceVersion{ + OptionalString: argparser.OptionalString{Value: "1"}, + }, + AutoClone: argparser.OptionalAutoClone{ + OptionalBool: argparser.OptionalBool{ + Optional: argparser.Optional{ + WasSet: true, + }, + Value: true, + }, + }, + NewName: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new1"}, + URL: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new2"}, + Format: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new3"}, + FormatVersion: argparser.OptionalInt{Optional: argparser.Optional{WasSet: true}, Value: 3}, + ResponseCondition: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new4"}, + Placement: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new5"}, + MessageType: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new6"}, + } +} + +func updateCommandMissingServiceID() *sumologic.UpdateCommand { + res := updateCommandAll() + res.Manifest = manifest.Data{} + return res +} diff --git a/pkg/commands/logging/sumologic/update.go b/pkg/commands/logging/sumologic/update.go new file mode 100644 index 000000000..0f99d7b88 --- /dev/null +++ b/pkg/commands/logging/sumologic/update.go @@ -0,0 +1,163 @@ +package sumologic + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/logging/common" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/text" +) + +// UpdateCommand calls the Fastly API to update a Sumologic logging endpoint. +type UpdateCommand struct { + argparser.Base + Manifest manifest.Data + + // Required. + EndpointName string // Can't shadow argparser.Base method Name(). + ServiceName argparser.OptionalServiceNameID + ServiceVersion argparser.OptionalServiceVersion + + // Optional. + AutoClone argparser.OptionalAutoClone + NewName argparser.OptionalString + URL argparser.OptionalString + Format argparser.OptionalString + ResponseCondition argparser.OptionalString + MessageType argparser.OptionalString + FormatVersion argparser.OptionalInt // Inconsistent with other logging endpoints, but remaining as int to avoid breaking changes in fastly/go-fastly. + Placement argparser.OptionalString +} + +// NewUpdateCommand returns a usable command registered under the parent. +func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { + c := UpdateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("update", "Update a Sumologic logging endpoint on a Fastly service version") + + // Required. + c.CmdClause.Flag("name", "The name of the Sumologic logging object").Short('n').Required().StringVar(&c.EndpointName) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.ServiceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.AutoClone.Set, + Dst: &c.AutoClone.Value, + }) + common.Format(c.CmdClause, &c.Format) + c.CmdClause.Flag("format-version", "The version of the custom logging format used for the configured endpoint. Can be either 2 (the default, version 2 log format) or 1 (the version 1 log format). The logging call gets placed by default in vcl_log if format_version is set to 2 and in vcl_deliver if format_version is set to 1").Action(c.FormatVersion.Set).IntVar(&c.FormatVersion.Value) + common.MessageType(c.CmdClause, &c.MessageType) + c.CmdClause.Flag("new-name", "New name of the Sumologic logging object").Action(c.NewName.Set).StringVar(&c.NewName.Value) + common.Placement(c.CmdClause, &c.Placement) + common.ResponseCondition(c.CmdClause, &c.ResponseCondition) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.ServiceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.ServiceName.Value, + }) + c.CmdClause.Flag("url", "The URL to POST to").Action(c.URL.Set).StringVar(&c.URL.Value) + return &c +} + +// ConstructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *UpdateCommand) ConstructInput(serviceID string, serviceVersion int) (*fastly.UpdateSumologicInput, error) { + input := fastly.UpdateSumologicInput{ + ServiceID: serviceID, + ServiceVersion: serviceVersion, + Name: c.EndpointName, + } + + // Set new values if set by user. + if c.NewName.WasSet { + input.NewName = &c.NewName.Value + } + + if c.URL.WasSet { + input.URL = &c.URL.Value + } + + if c.Format.WasSet { + input.Format = fastly.ToPointer(argparser.Content(c.Format.Value)) + } + + if c.ResponseCondition.WasSet { + input.ResponseCondition = &c.ResponseCondition.Value + } + + if c.MessageType.WasSet { + input.MessageType = &c.MessageType.Value + } + + if c.FormatVersion.WasSet { + input.FormatVersion = &c.FormatVersion.Value + } + + if c.Placement.WasSet { + input.Placement = &c.Placement.Value + } + + return &input, nil +} + +// Exec invokes the application logic for the command. +func (c *UpdateCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.AutoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.ServiceName, + ServiceVersionFlag: c.ServiceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + input, err := c.ConstructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + sumologic, err := c.Globals.APIClient.UpdateSumologic(input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + text.Success(out, + "Updated Sumologic logging endpoint %s (service %s version %d)", + fastly.ToValue(sumologic.Name), + fastly.ToValue(sumologic.ServiceID), + fastly.ToValue(sumologic.ServiceVersion), + ) + return nil +} diff --git a/pkg/commands/logging/syslog/create.go b/pkg/commands/logging/syslog/create.go new file mode 100644 index 000000000..16f0f1864 --- /dev/null +++ b/pkg/commands/logging/syslog/create.go @@ -0,0 +1,199 @@ +package syslog + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/logging/common" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/text" +) + +// CreateCommand calls the Fastly API to create a Syslog logging endpoint. +type CreateCommand struct { + argparser.Base + Manifest manifest.Data + + // Required. + ServiceName argparser.OptionalServiceNameID + ServiceVersion argparser.OptionalServiceVersion + + // Optional. + Address argparser.OptionalString + AutoClone argparser.OptionalAutoClone + EndpointName argparser.OptionalString // Can't shadow argparser.Base method Name(). + Format argparser.OptionalString + FormatVersion argparser.OptionalInt + MessageType argparser.OptionalString + Placement argparser.OptionalString + Port argparser.OptionalInt + ResponseCondition argparser.OptionalString + TLSCACert argparser.OptionalString + TLSClientCert argparser.OptionalString + TLSClientKey argparser.OptionalString + TLSHostname argparser.OptionalString + Token argparser.OptionalString + UseTLS argparser.OptionalBool +} + +// NewCreateCommand returns a usable command registered under the parent. +func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { + c := CreateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("create", "Create a Syslog logging endpoint on a Fastly service version").Alias("add") + + // Required. + c.CmdClause.Flag("name", "The name of the Syslog logging object. Used as a primary key for API access").Short('n').Action(c.EndpointName.Set).StringVar(&c.EndpointName.Value) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.ServiceVersion.Value, + Required: true, + }) + + // Optional. + c.CmdClause.Flag("address", "A hostname or IPv4 address").Action(c.Address.Set).StringVar(&c.Address.Value) + c.CmdClause.Flag("auth-token", "Whether to prepend each message with a specific token").Action(c.Token.Set).StringVar(&c.Token.Value) + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.AutoClone.Set, + Dst: &c.AutoClone.Value, + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + common.Format(c.CmdClause, &c.Format) + common.FormatVersion(c.CmdClause, &c.FormatVersion) + common.MessageType(c.CmdClause, &c.MessageType) + common.Placement(c.CmdClause, &c.Placement) + c.CmdClause.Flag("port", "The port number").Action(c.Port.Set).IntVar(&c.Port.Value) + common.ResponseCondition(c.CmdClause, &c.ResponseCondition) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.ServiceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.ServiceName.Value, + }) + common.TLSCACert(c.CmdClause, &c.TLSCACert) + common.TLSClientCert(c.CmdClause, &c.TLSClientCert) + common.TLSClientKey(c.CmdClause, &c.TLSClientKey) + common.TLSHostname(c.CmdClause, &c.TLSHostname) + c.CmdClause.Flag("use-tls", "Whether to use TLS for secure logging. Can be either true or false").Action(c.UseTLS.Set).BoolVar(&c.UseTLS.Value) + return &c +} + +// ConstructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *CreateCommand) ConstructInput(serviceID string, serviceVersion int) (*fastly.CreateSyslogInput, error) { + var input fastly.CreateSyslogInput + + input.ServiceID = serviceID + if c.EndpointName.WasSet { + input.Name = &c.EndpointName.Value + } + input.ServiceVersion = serviceVersion + if c.Address.WasSet { + input.Address = &c.Address.Value + } + + if c.Port.WasSet { + input.Port = &c.Port.Value + } + + if c.UseTLS.WasSet { + input.UseTLS = fastly.ToPointer(fastly.Compatibool(c.UseTLS.Value)) + } + + if c.TLSCACert.WasSet { + input.TLSCACert = &c.TLSCACert.Value + } + + if c.TLSHostname.WasSet { + input.TLSHostname = &c.TLSHostname.Value + } + + if c.TLSClientCert.WasSet { + input.TLSClientCert = &c.TLSClientCert.Value + } + + if c.TLSClientKey.WasSet { + input.TLSClientKey = &c.TLSClientKey.Value + } + + if c.Token.WasSet { + input.Token = &c.Token.Value + } + + if c.Format.WasSet { + input.Format = fastly.ToPointer(argparser.Content(c.Format.Value)) + } + + if c.FormatVersion.WasSet { + input.FormatVersion = &c.FormatVersion.Value + } + + if c.MessageType.WasSet { + input.MessageType = &c.MessageType.Value + } + + if c.ResponseCondition.WasSet { + input.ResponseCondition = &c.ResponseCondition.Value + } + + if c.Placement.WasSet { + input.Placement = &c.Placement.Value + } + return &input, nil +} + +// Exec invokes the application logic for the command. +func (c *CreateCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.AutoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.ServiceName, + ServiceVersionFlag: c.ServiceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + input, err := c.ConstructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + d, err := c.Globals.APIClient.CreateSyslog(input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + text.Success(out, + "Created Syslog logging endpoint %s (service %s version %d)", + fastly.ToValue(d.Name), + fastly.ToValue(d.ServiceID), + fastly.ToValue(d.ServiceVersion), + ) + return nil +} diff --git a/pkg/commands/logging/syslog/delete.go b/pkg/commands/logging/syslog/delete.go new file mode 100644 index 000000000..74b11be19 --- /dev/null +++ b/pkg/commands/logging/syslog/delete.go @@ -0,0 +1,94 @@ +package syslog + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// DeleteCommand calls the Fastly API to delete a Syslog logging endpoint. +type DeleteCommand struct { + argparser.Base + Input fastly.DeleteSyslogInput + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion + autoClone argparser.OptionalAutoClone +} + +// NewDeleteCommand returns a usable command registered under the parent. +func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { + c := DeleteCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("delete", "Delete a Syslog logging endpoint on a Fastly service version").Alias("remove") + + // Required. + c.CmdClause.Flag("name", "The name of the Syslog logging object").Short('n').Required().StringVar(&c.Input.Name) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.autoClone.Set, + Dst: &c.autoClone.Value, + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + return &c +} + +// Exec invokes the application logic for the command. +func (c *DeleteCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.autoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + c.Input.ServiceID = serviceID + c.Input.ServiceVersion = fastly.ToValue(serviceVersion.Number) + + if err := c.Globals.APIClient.DeleteSyslog(&c.Input); err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + text.Success(out, "Deleted Syslog logging endpoint %s (service %s version %d)", c.Input.Name, c.Input.ServiceID, c.Input.ServiceVersion) + return nil +} diff --git a/pkg/commands/logging/syslog/describe.go b/pkg/commands/logging/syslog/describe.go new file mode 100644 index 000000000..0a65238cc --- /dev/null +++ b/pkg/commands/logging/syslog/describe.go @@ -0,0 +1,119 @@ +package syslog + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// DescribeCommand calls the Fastly API to describe a Syslog logging endpoint. +type DescribeCommand struct { + argparser.Base + argparser.JSONOutput + + Input fastly.GetSyslogInput + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion +} + +// NewDescribeCommand returns a usable command registered under the parent. +func NewDescribeCommand(parent argparser.Registerer, g *global.Data) *DescribeCommand { + c := DescribeCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("describe", "Show detailed information about a Syslog logging endpoint on a Fastly service version").Alias("get") + + // Required. + c.CmdClause.Flag("name", "The name of the Syslog logging object").Short('n').Required().StringVar(&c.Input.Name) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + return &c +} + +// Exec invokes the application logic for the command. +func (c *DescribeCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fsterr.ServiceVersion(serviceVersion), + }) + return err + } + + c.Input.ServiceID = serviceID + c.Input.ServiceVersion = fastly.ToValue(serviceVersion.Number) + + o, err := c.Globals.APIClient.GetSyslog(&c.Input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + lines := text.Lines{ + "Address": fastly.ToValue(o.Address), + "Format version": fastly.ToValue(o.FormatVersion), + "Format": fastly.ToValue(o.Format), + "Hostname": fastly.ToValue(o.Hostname), + "IPV4": fastly.ToValue(o.IPV4), + "Message type": fastly.ToValue(o.MessageType), + "Name": fastly.ToValue(o.Name), + "Placement": fastly.ToValue(o.Placement), + "Port": fastly.ToValue(o.Port), + "Response condition": fastly.ToValue(o.ResponseCondition), + "TLS CA certificate": fastly.ToValue(o.TLSCACert), + "TLS client certificate": fastly.ToValue(o.TLSClientCert), + "TLS client key": fastly.ToValue(o.TLSClientKey), + "TLS hostname": fastly.ToValue(o.TLSHostname), + "Token": fastly.ToValue(o.Token), + "Use TLS": fastly.ToValue(o.UseTLS), + "Version": fastly.ToValue(o.ServiceVersion), + } + if !c.Globals.Verbose() { + lines["Service ID"] = fastly.ToValue(o.ServiceID) + } + text.PrintLines(out, lines) + + return nil +} diff --git a/pkg/logging/syslog/doc.go b/pkg/commands/logging/syslog/doc.go similarity index 100% rename from pkg/logging/syslog/doc.go rename to pkg/commands/logging/syslog/doc.go diff --git a/pkg/commands/logging/syslog/list.go b/pkg/commands/logging/syslog/list.go new file mode 100644 index 000000000..71eae7839 --- /dev/null +++ b/pkg/commands/logging/syslog/list.go @@ -0,0 +1,133 @@ +package syslog + +import ( + "fmt" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// ListCommand calls the Fastly API to list Syslog logging endpoints. +type ListCommand struct { + argparser.Base + argparser.JSONOutput + + Input fastly.ListSyslogsInput + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion +} + +// NewListCommand returns a usable command registered under the parent. +func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { + c := ListCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("list", "List Syslog endpoints on a Fastly service version") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + return &c +} + +// Exec invokes the application logic for the command. +func (c *ListCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fsterr.ServiceVersion(serviceVersion), + }) + return err + } + + c.Input.ServiceID = serviceID + c.Input.ServiceVersion = fastly.ToValue(serviceVersion.Number) + + o, err := c.Globals.APIClient.ListSyslogs(&c.Input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + if !c.Globals.Verbose() { + tw := text.NewTable(out) + tw.AddHeader("SERVICE", "VERSION", "NAME") + for _, syslog := range o { + tw.AddLine( + fastly.ToValue(syslog.ServiceID), + fastly.ToValue(syslog.ServiceVersion), + fastly.ToValue(syslog.Name), + ) + } + tw.Print() + return nil + } + + fmt.Fprintf(out, "Version: %d\n", c.Input.ServiceVersion) + for i, syslog := range o { + fmt.Fprintf(out, "\tSyslog %d/%d\n", i+1, len(o)) + fmt.Fprintf(out, "\t\tService ID: %s\n", fastly.ToValue(syslog.ServiceID)) + fmt.Fprintf(out, "\t\tVersion: %d\n", fastly.ToValue(syslog.ServiceVersion)) + fmt.Fprintf(out, "\t\tName: %s\n", fastly.ToValue(syslog.Name)) + fmt.Fprintf(out, "\t\tAddress: %s\n", fastly.ToValue(syslog.Address)) + fmt.Fprintf(out, "\t\tHostname: %s\n", fastly.ToValue(syslog.Hostname)) + fmt.Fprintf(out, "\t\tPort: %d\n", fastly.ToValue(syslog.Port)) + fmt.Fprintf(out, "\t\tUse TLS: %t\n", fastly.ToValue(syslog.UseTLS)) + fmt.Fprintf(out, "\t\tIPV4: %s\n", fastly.ToValue(syslog.IPV4)) + fmt.Fprintf(out, "\t\tTLS CA certificate: %s\n", fastly.ToValue(syslog.TLSCACert)) + fmt.Fprintf(out, "\t\tTLS hostname: %s\n", fastly.ToValue(syslog.TLSHostname)) + fmt.Fprintf(out, "\t\tTLS client certificate: %s\n", fastly.ToValue(syslog.TLSClientCert)) + fmt.Fprintf(out, "\t\tTLS client key: %s\n", fastly.ToValue(syslog.TLSClientKey)) + fmt.Fprintf(out, "\t\tToken: %s\n", fastly.ToValue(syslog.Token)) + fmt.Fprintf(out, "\t\tFormat: %s\n", fastly.ToValue(syslog.Format)) + fmt.Fprintf(out, "\t\tFormat version: %d\n", fastly.ToValue(syslog.FormatVersion)) + fmt.Fprintf(out, "\t\tMessage type: %s\n", fastly.ToValue(syslog.MessageType)) + fmt.Fprintf(out, "\t\tResponse condition: %s\n", fastly.ToValue(syslog.ResponseCondition)) + fmt.Fprintf(out, "\t\tPlacement: %s\n", fastly.ToValue(syslog.Placement)) + } + fmt.Fprintln(out) + + return nil +} diff --git a/pkg/commands/logging/syslog/root.go b/pkg/commands/logging/syslog/root.go new file mode 100644 index 000000000..63575a9b3 --- /dev/null +++ b/pkg/commands/logging/syslog/root.go @@ -0,0 +1,31 @@ +package syslog + +import ( + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" +) + +// RootCommand is the parent command for all subcommands in this package. +// It should be installed under the primary root command. +type RootCommand struct { + argparser.Base + // no flags +} + +// CommandName is the string to be used to invoke this command. +const CommandName = "syslog" + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent argparser.Registerer, g *global.Data) *RootCommand { + var c RootCommand + c.Globals = g + c.CmdClause = parent.Command(CommandName, "Manipulate Fastly service version Syslog logging endpoints") + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { + panic("unreachable") +} diff --git a/pkg/commands/logging/syslog/syslog_integration_test.go b/pkg/commands/logging/syslog/syslog_integration_test.go new file mode 100644 index 000000000..b9c840074 --- /dev/null +++ b/pkg/commands/logging/syslog/syslog_integration_test.go @@ -0,0 +1,470 @@ +package syslog_test + +import ( + "bytes" + "errors" + "io" + "strings" + "testing" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/app" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" +) + +func TestSyslogCreate(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging syslog create --service-id 123 --version 1 --name log --address 127.0.0.1 --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + CreateSyslogFn: createSyslogOK, + }, + wantOutput: "Created Syslog logging endpoint log (service 123 version 4)", + }, + { + args: args("logging syslog create --service-id 123 --version 1 --name log --address 127.0.0.1 --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + CreateSyslogFn: createSyslogError, + }, + wantError: errTest.Error(), + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertStringContains(t, stdout.String(), testcase.wantOutput) + }) + } +} + +func TestSyslogList(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging syslog list --service-id 123 --version 1"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListSyslogsFn: listSyslogsOK, + }, + wantOutput: listSyslogsShortOutput, + }, + { + args: args("logging syslog list --service-id 123 --version 1 --verbose"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListSyslogsFn: listSyslogsOK, + }, + wantOutput: listSyslogsVerboseOutput, + }, + { + args: args("logging syslog list --service-id 123 --version 1 -v"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListSyslogsFn: listSyslogsOK, + }, + wantOutput: listSyslogsVerboseOutput, + }, + { + args: args("logging syslog --verbose list --service-id 123 --version 1"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListSyslogsFn: listSyslogsOK, + }, + wantOutput: listSyslogsVerboseOutput, + }, + { + args: args("logging -v syslog list --service-id 123 --version 1"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListSyslogsFn: listSyslogsOK, + }, + wantOutput: listSyslogsVerboseOutput, + }, + { + args: args("logging syslog list --service-id 123 --version 1"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListSyslogsFn: listSyslogsError, + }, + wantError: errTest.Error(), + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertString(t, testcase.wantOutput, stdout.String()) + }) + } +} + +func TestSyslogDescribe(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging syslog describe --service-id 123 --version 1"), + wantError: "error parsing arguments: required flag --name not provided", + }, + { + args: args("logging syslog describe --service-id 123 --version 1 --name logs"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + GetSyslogFn: getSyslogError, + }, + wantError: errTest.Error(), + }, + { + args: args("logging syslog describe --service-id 123 --version 1 --name logs"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + GetSyslogFn: getSyslogOK, + }, + wantOutput: describeSyslogOutput, + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertString(t, testcase.wantOutput, stdout.String()) + }) + } +} + +func TestSyslogUpdate(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging syslog update --service-id 123 --version 1 --new-name log"), + wantError: "error parsing arguments: required flag --name not provided", + }, + { + args: args("logging syslog update --service-id 123 --version 1 --name logs --new-name log --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + UpdateSyslogFn: updateSyslogError, + }, + wantError: errTest.Error(), + }, + { + args: args("logging syslog update --service-id 123 --version 1 --name logs --new-name log --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + UpdateSyslogFn: updateSyslogOK, + }, + wantOutput: "Updated Syslog logging endpoint log (service 123 version 4)", + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertStringContains(t, stdout.String(), testcase.wantOutput) + }) + } +} + +func TestSyslogDelete(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("logging syslog delete --service-id 123 --version 1"), + wantError: "error parsing arguments: required flag --name not provided", + }, + { + args: args("logging syslog delete --service-id 123 --version 1 --name logs --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + DeleteSyslogFn: deleteSyslogError, + }, + wantError: errTest.Error(), + }, + { + args: args("logging syslog delete --service-id 123 --version 1 --name logs --autoclone"), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + DeleteSyslogFn: deleteSyslogOK, + }, + wantOutput: "Deleted Syslog logging endpoint logs (service 123 version 4)", + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertStringContains(t, stdout.String(), testcase.wantOutput) + }) + } +} + +var errTest = errors.New("fixture error") + +func createSyslogOK(i *fastly.CreateSyslogInput) (*fastly.Syslog, error) { + return &fastly.Syslog{ + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: i.Name, + }, nil +} + +func createSyslogError(_ *fastly.CreateSyslogInput) (*fastly.Syslog, error) { + return nil, errTest +} + +func listSyslogsOK(i *fastly.ListSyslogsInput) ([]*fastly.Syslog, error) { + return []*fastly.Syslog{ + { + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("logs"), + Address: fastly.ToPointer("127.0.0.1"), + Hostname: fastly.ToPointer("127.0.0.1"), + Port: fastly.ToPointer(514), + UseTLS: fastly.ToPointer(false), + IPV4: fastly.ToPointer("127.0.0.1"), + TLSCACert: fastly.ToPointer("-----BEGIN CERTIFICATE-----foo"), + TLSHostname: fastly.ToPointer("example.com"), + TLSClientCert: fastly.ToPointer("-----BEGIN CERTIFICATE-----bar"), + TLSClientKey: fastly.ToPointer("-----BEGIN PRIVATE KEY-----bar"), + Token: fastly.ToPointer("tkn"), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + FormatVersion: fastly.ToPointer(2), + MessageType: fastly.ToPointer("classic"), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + Placement: fastly.ToPointer("none"), + }, + { + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("analytics"), + Address: fastly.ToPointer("example.com"), + Hostname: fastly.ToPointer("example.com"), + Port: fastly.ToPointer(789), + UseTLS: fastly.ToPointer(true), + IPV4: fastly.ToPointer("127.0.0.1"), + TLSCACert: fastly.ToPointer("-----BEGIN CERTIFICATE-----baz"), + TLSHostname: fastly.ToPointer("example.com"), + TLSClientCert: fastly.ToPointer("-----BEGIN CERTIFICATE-----qux"), + TLSClientKey: fastly.ToPointer("-----BEGIN PRIVATE KEY-----qux"), + Token: fastly.ToPointer("tkn"), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + FormatVersion: fastly.ToPointer(2), + MessageType: fastly.ToPointer("classic"), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + Placement: fastly.ToPointer("none"), + }, + }, nil +} + +func listSyslogsError(_ *fastly.ListSyslogsInput) ([]*fastly.Syslog, error) { + return nil, errTest +} + +var listSyslogsShortOutput = strings.TrimSpace(` +SERVICE VERSION NAME +123 1 logs +123 1 analytics +`) + "\n" + +var listSyslogsVerboseOutput = strings.TrimSpace(` +Fastly API endpoint: https://api.fastly.com +Fastly API token provided via config file (profile: user) + +Service ID (via --service-id): 123 + +Version: 1 + Syslog 1/2 + Service ID: 123 + Version: 1 + Name: logs + Address: 127.0.0.1 + Hostname: 127.0.0.1 + Port: 514 + Use TLS: false + IPV4: 127.0.0.1 + TLS CA certificate: -----BEGIN CERTIFICATE-----foo + TLS hostname: example.com + TLS client certificate: -----BEGIN CERTIFICATE-----bar + TLS client key: -----BEGIN PRIVATE KEY-----bar + Token: tkn + Format: %h %l %u %t "%r" %>s %b + Format version: 2 + Message type: classic + Response condition: Prevent default logging + Placement: none + Syslog 2/2 + Service ID: 123 + Version: 1 + Name: analytics + Address: example.com + Hostname: example.com + Port: 789 + Use TLS: true + IPV4: 127.0.0.1 + TLS CA certificate: -----BEGIN CERTIFICATE-----baz + TLS hostname: example.com + TLS client certificate: -----BEGIN CERTIFICATE-----qux + TLS client key: -----BEGIN PRIVATE KEY-----qux + Token: tkn + Format: %h %l %u %t "%r" %>s %b + Format version: 2 + Message type: classic + Response condition: Prevent default logging + Placement: none +`) + "\n\n" + +func getSyslogOK(i *fastly.GetSyslogInput) (*fastly.Syslog, error) { + return &fastly.Syslog{ + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("logs"), + Address: fastly.ToPointer("example.com"), + Hostname: fastly.ToPointer("example.com"), + Port: fastly.ToPointer(514), + UseTLS: fastly.ToPointer(true), + IPV4: fastly.ToPointer(""), + TLSCACert: fastly.ToPointer("-----BEGIN CERTIFICATE-----foo"), + TLSHostname: fastly.ToPointer("example.com"), + TLSClientCert: fastly.ToPointer("-----BEGIN CERTIFICATE-----bar"), + TLSClientKey: fastly.ToPointer("-----BEGIN PRIVATE KEY-----bar"), + Token: fastly.ToPointer("tkn"), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + FormatVersion: fastly.ToPointer(2), + MessageType: fastly.ToPointer("classic"), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + Placement: fastly.ToPointer("none"), + }, nil +} + +func getSyslogError(_ *fastly.GetSyslogInput) (*fastly.Syslog, error) { + return nil, errTest +} + +var describeSyslogOutput = ` +Address: example.com +Format: %h %l %u %t "%r" %>s %b +Format version: 2 +Hostname: example.com +IPV4: ` + ` +Message type: classic +Name: logs +Placement: none +Port: 514 +Response condition: Prevent default logging +Service ID: 123 +TLS CA certificate: -----BEGIN CERTIFICATE-----foo +TLS client certificate: -----BEGIN CERTIFICATE-----bar +TLS client key: -----BEGIN PRIVATE KEY-----bar +TLS hostname: example.com +Token: tkn +Use TLS: true +Version: 1 +` + +func updateSyslogOK(i *fastly.UpdateSyslogInput) (*fastly.Syslog, error) { + return &fastly.Syslog{ + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("log"), + Address: fastly.ToPointer("example.com"), + Hostname: fastly.ToPointer("example.com"), + Port: fastly.ToPointer(514), + UseTLS: fastly.ToPointer(true), + IPV4: fastly.ToPointer(""), + TLSCACert: fastly.ToPointer("-----BEGIN CERTIFICATE-----foo"), + TLSHostname: fastly.ToPointer("example.com"), + TLSClientCert: fastly.ToPointer("-----BEGIN CERTIFICATE-----bar"), + TLSClientKey: fastly.ToPointer("-----BEGIN PRIVATE KEY-----bar"), + Token: fastly.ToPointer("tkn"), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + FormatVersion: fastly.ToPointer(2), + MessageType: fastly.ToPointer("classic"), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + Placement: fastly.ToPointer("none"), + }, nil +} + +func updateSyslogError(_ *fastly.UpdateSyslogInput) (*fastly.Syslog, error) { + return nil, errTest +} + +func deleteSyslogOK(_ *fastly.DeleteSyslogInput) error { + return nil +} + +func deleteSyslogError(_ *fastly.DeleteSyslogInput) error { + return errTest +} diff --git a/pkg/commands/logging/syslog/syslog_test.go b/pkg/commands/logging/syslog/syslog_test.go new file mode 100644 index 000000000..d9d2b114a --- /dev/null +++ b/pkg/commands/logging/syslog/syslog_test.go @@ -0,0 +1,368 @@ +package syslog_test + +import ( + "bytes" + "testing" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/logging/syslog" + "github.com/fastly/cli/pkg/config" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" +) + +func TestCreateSyslogInput(t *testing.T) { + for _, testcase := range []struct { + name string + cmd *syslog.CreateCommand + want *fastly.CreateSyslogInput + wantError string + }{ + { + name: "required values set flag serviceID", + cmd: createCommandRequired(), + want: &fastly.CreateSyslogInput{ + ServiceID: "123", + ServiceVersion: 4, + Name: fastly.ToPointer("log"), + Address: fastly.ToPointer("example.com"), + }, + }, + { + name: "all values set flag serviceID", + cmd: createCommandAll(), + want: &fastly.CreateSyslogInput{ + ServiceID: "123", + ServiceVersion: 4, + Name: fastly.ToPointer("log"), + Address: fastly.ToPointer("example.com"), + Port: fastly.ToPointer(22), + UseTLS: fastly.ToPointer(fastly.Compatibool(true)), + TLSCACert: fastly.ToPointer("-----BEGIN CERTIFICATE-----foo"), + TLSHostname: fastly.ToPointer("example.com"), + TLSClientCert: fastly.ToPointer("-----BEGIN CERTIFICATE-----bar"), + TLSClientKey: fastly.ToPointer("-----BEGIN PRIVATE KEY-----bar"), + Token: fastly.ToPointer("tkn"), + Format: fastly.ToPointer(`%h %l %u %t "%r" %>s %b`), + FormatVersion: fastly.ToPointer(2), + MessageType: fastly.ToPointer("classic"), + ResponseCondition: fastly.ToPointer("Prevent default logging"), + Placement: fastly.ToPointer("none"), + }, + }, + { + name: "error missing serviceID", + cmd: createCommandMissingServiceID(), + want: nil, + wantError: errors.ErrNoServiceID.Error(), + }, + } { + t.Run(testcase.name, func(t *testing.T) { + var bs []byte + out := bytes.NewBuffer(bs) + verboseMode := true + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + AutoCloneFlag: testcase.cmd.AutoClone, + APIClient: testcase.cmd.Globals.APIClient, + Manifest: testcase.cmd.Manifest, + Out: out, + ServiceVersionFlag: testcase.cmd.ServiceVersion, + VerboseMode: verboseMode, + }) + + switch { + case err != nil && testcase.wantError == "": + t.Fatalf("unexpected error getting service details: %v", err) + return + case err != nil && testcase.wantError != "": + testutil.AssertErrorContains(t, err, testcase.wantError) + return + case err == nil && testcase.wantError != "": + t.Fatalf("expected error, have nil (service details: %s, %d)", serviceID, serviceVersion.Number) + case err == nil && testcase.wantError == "": + have, err := testcase.cmd.ConstructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertEqual(t, testcase.want, have) + } + }) + } +} + +func TestUpdateSyslogInput(t *testing.T) { + scenarios := []struct { + name string + cmd *syslog.UpdateCommand + api mock.API + want *fastly.UpdateSyslogInput + wantError string + }{ + { + name: "no updates", + cmd: updateCommandNoUpdates(), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + GetSyslogFn: getSyslogOK, + }, + want: &fastly.UpdateSyslogInput{ + ServiceID: "123", + ServiceVersion: 4, + Name: "log", + }, + }, + { + name: "all values set flag serviceID", + cmd: updateCommandAll(), + api: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + GetSyslogFn: getSyslogOK, + }, + want: &fastly.UpdateSyslogInput{ + ServiceID: "123", + ServiceVersion: 4, + Name: "log", + NewName: fastly.ToPointer("new1"), + Address: fastly.ToPointer("new2"), + Port: fastly.ToPointer(23), + UseTLS: fastly.ToPointer(fastly.Compatibool(false)), + TLSCACert: fastly.ToPointer("new3"), + TLSHostname: fastly.ToPointer("new4"), + TLSClientCert: fastly.ToPointer("new5"), + TLSClientKey: fastly.ToPointer("new6"), + Token: fastly.ToPointer("new7"), + Format: fastly.ToPointer("new8"), + FormatVersion: fastly.ToPointer(3), + MessageType: fastly.ToPointer("new9"), + ResponseCondition: fastly.ToPointer("new10"), + Placement: fastly.ToPointer("new11"), + }, + }, + { + name: "error missing serviceID", + cmd: updateCommandMissingServiceID(), + want: nil, + wantError: errors.ErrNoServiceID.Error(), + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(testcase.name, func(t *testing.T) { + testcase.cmd.Globals.APIClient = testcase.api + + var bs []byte + out := bytes.NewBuffer(bs) + verboseMode := true + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + AutoCloneFlag: testcase.cmd.AutoClone, + APIClient: testcase.api, + Manifest: testcase.cmd.Manifest, + Out: out, + ServiceVersionFlag: testcase.cmd.ServiceVersion, + VerboseMode: verboseMode, + }) + + switch { + case err != nil && testcase.wantError == "": + t.Fatalf("unexpected error getting service details: %v", err) + return + case err != nil && testcase.wantError != "": + testutil.AssertErrorContains(t, err, testcase.wantError) + return + case err == nil && testcase.wantError != "": + t.Fatalf("expected error, have nil (service details: %s, %d)", serviceID, serviceVersion.Number) + case err == nil && testcase.wantError == "": + have, err := testcase.cmd.ConstructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertEqual(t, testcase.want, have) + } + }) + } +} + +func createCommandRequired() *syslog.CreateCommand { + var b bytes.Buffer + + g := global.Data{ + Config: config.File{}, + Env: config.Environment{}, + Output: &b, + } + g.APIClient, _ = mock.APIClient(mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + })("token", "endpoint", false) + + return &syslog.CreateCommand{ + Base: argparser.Base{ + Globals: &g, + }, + Manifest: manifest.Data{ + Flag: manifest.Flag{ + ServiceID: "123", + }, + }, + EndpointName: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "log"}, + Address: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "example.com"}, + ServiceVersion: argparser.OptionalServiceVersion{ + OptionalString: argparser.OptionalString{Value: "1"}, + }, + AutoClone: argparser.OptionalAutoClone{ + OptionalBool: argparser.OptionalBool{ + Optional: argparser.Optional{ + WasSet: true, + }, + Value: true, + }, + }, + } +} + +func createCommandAll() *syslog.CreateCommand { + var b bytes.Buffer + + g := global.Data{ + Config: config.File{}, + Env: config.Environment{}, + Output: &b, + } + g.APIClient, _ = mock.APIClient(mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + })("token", "endpoint", false) + + return &syslog.CreateCommand{ + Base: argparser.Base{ + Globals: &g, + }, + Manifest: manifest.Data{ + Flag: manifest.Flag{ + ServiceID: "123", + }, + }, + ServiceVersion: argparser.OptionalServiceVersion{ + OptionalString: argparser.OptionalString{Value: "1"}, + }, + AutoClone: argparser.OptionalAutoClone{ + OptionalBool: argparser.OptionalBool{ + Optional: argparser.Optional{ + WasSet: true, + }, + Value: true, + }, + }, + EndpointName: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "log"}, + Address: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "example.com"}, + Format: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: `%h %l %u %t "%r" %>s %b`}, + FormatVersion: argparser.OptionalInt{Optional: argparser.Optional{WasSet: true}, Value: 2}, + ResponseCondition: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "Prevent default logging"}, + Placement: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "none"}, + Port: argparser.OptionalInt{Optional: argparser.Optional{WasSet: true}, Value: 22}, + UseTLS: argparser.OptionalBool{Optional: argparser.Optional{WasSet: true}, Value: true}, + TLSCACert: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "-----BEGIN CERTIFICATE-----foo"}, + TLSHostname: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "example.com"}, + TLSClientCert: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "-----BEGIN CERTIFICATE-----bar"}, + TLSClientKey: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "-----BEGIN PRIVATE KEY-----bar"}, + Token: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "tkn"}, + MessageType: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "classic"}, + } +} + +func createCommandMissingServiceID() *syslog.CreateCommand { + res := createCommandAll() + res.Manifest = manifest.Data{} + return res +} + +func updateCommandNoUpdates() *syslog.UpdateCommand { + var b bytes.Buffer + + g := global.Data{ + Config: config.File{}, + Env: config.Environment{}, + Output: &b, + } + + return &syslog.UpdateCommand{ + Base: argparser.Base{ + Globals: &g, + }, + Manifest: manifest.Data{ + Flag: manifest.Flag{ + ServiceID: "123", + }, + }, + EndpointName: "log", + ServiceVersion: argparser.OptionalServiceVersion{ + OptionalString: argparser.OptionalString{Value: "1"}, + }, + AutoClone: argparser.OptionalAutoClone{ + OptionalBool: argparser.OptionalBool{ + Optional: argparser.Optional{ + WasSet: true, + }, + Value: true, + }, + }, + } +} + +func updateCommandAll() *syslog.UpdateCommand { + var b bytes.Buffer + + g := global.Data{ + Config: config.File{}, + Env: config.Environment{}, + Output: &b, + } + + return &syslog.UpdateCommand{ + Base: argparser.Base{ + Globals: &g, + }, + Manifest: manifest.Data{ + Flag: manifest.Flag{ + ServiceID: "123", + }, + }, + EndpointName: "log", + ServiceVersion: argparser.OptionalServiceVersion{ + OptionalString: argparser.OptionalString{Value: "1"}, + }, + AutoClone: argparser.OptionalAutoClone{ + OptionalBool: argparser.OptionalBool{ + Optional: argparser.Optional{ + WasSet: true, + }, + Value: true, + }, + }, + NewName: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new1"}, + Address: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new2"}, + Port: argparser.OptionalInt{Optional: argparser.Optional{WasSet: true}, Value: 23}, + UseTLS: argparser.OptionalBool{Optional: argparser.Optional{WasSet: true}, Value: false}, + TLSCACert: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new3"}, + TLSHostname: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new4"}, + TLSClientCert: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new5"}, + TLSClientKey: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new6"}, + Token: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new7"}, + Format: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new8"}, + FormatVersion: argparser.OptionalInt{Optional: argparser.Optional{WasSet: true}, Value: 3}, + MessageType: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new9"}, + ResponseCondition: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new10"}, + Placement: argparser.OptionalString{Optional: argparser.Optional{WasSet: true}, Value: "new11"}, + } +} + +func updateCommandMissingServiceID() *syslog.UpdateCommand { + res := updateCommandAll() + res.Manifest = manifest.Data{} + return res +} diff --git a/pkg/commands/logging/syslog/update.go b/pkg/commands/logging/syslog/update.go new file mode 100644 index 000000000..6d0d80fea --- /dev/null +++ b/pkg/commands/logging/syslog/update.go @@ -0,0 +1,206 @@ +package syslog + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/logging/common" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/text" +) + +// UpdateCommand calls the Fastly API to update a Syslog logging endpoint. +type UpdateCommand struct { + argparser.Base + Manifest manifest.Data + + // Required. + EndpointName string + ServiceName argparser.OptionalServiceNameID + ServiceVersion argparser.OptionalServiceVersion + + // Optional. + AutoClone argparser.OptionalAutoClone + NewName argparser.OptionalString + Address argparser.OptionalString + Port argparser.OptionalInt + UseTLS argparser.OptionalBool + TLSCACert argparser.OptionalString + TLSHostname argparser.OptionalString + TLSClientCert argparser.OptionalString + TLSClientKey argparser.OptionalString + Token argparser.OptionalString + Format argparser.OptionalString + FormatVersion argparser.OptionalInt + MessageType argparser.OptionalString + ResponseCondition argparser.OptionalString + Placement argparser.OptionalString +} + +// NewUpdateCommand returns a usable command registered under the parent. +func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { + c := UpdateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("update", "Update a Syslog logging endpoint on a Fastly service version") + + // Required. + c.CmdClause.Flag("name", "The name of the Syslog logging object").Short('n').Required().StringVar(&c.EndpointName) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.ServiceVersion.Value, + Required: true, + }) + + // Optional. + c.CmdClause.Flag("address", "A hostname or IPv4 address").Action(c.Address.Set).StringVar(&c.Address.Value) + c.CmdClause.Flag("auth-token", "Whether to prepend each message with a specific token").Action(c.Token.Set).StringVar(&c.Token.Value) + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.AutoClone.Set, + Dst: &c.AutoClone.Value, + }) + common.Format(c.CmdClause, &c.Format) + common.FormatVersion(c.CmdClause, &c.FormatVersion) + c.CmdClause.Flag("new-name", "New name of the Syslog logging object").Action(c.NewName.Set).StringVar(&c.NewName.Value) + common.MessageType(c.CmdClause, &c.MessageType) + common.Placement(c.CmdClause, &c.Placement) + c.CmdClause.Flag("port", "The port number").Action(c.Port.Set).IntVar(&c.Port.Value) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.ServiceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.ServiceName.Value, + }) + common.ResponseCondition(c.CmdClause, &c.ResponseCondition) + common.TLSCACert(c.CmdClause, &c.TLSCACert) + common.TLSClientCert(c.CmdClause, &c.TLSClientCert) + common.TLSClientKey(c.CmdClause, &c.TLSClientKey) + c.CmdClause.Flag("tls-hostname", "Used during the TLS handshake to validate the certificate").Action(c.TLSHostname.Set).StringVar(&c.TLSHostname.Value) + c.CmdClause.Flag("use-tls", "Whether to use TLS for secure logging. Can be either true or false").Action(c.UseTLS.Set).BoolVar(&c.UseTLS.Value) + return &c +} + +// ConstructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *UpdateCommand) ConstructInput(serviceID string, serviceVersion int) (*fastly.UpdateSyslogInput, error) { + input := fastly.UpdateSyslogInput{ + ServiceID: serviceID, + ServiceVersion: serviceVersion, + Name: c.EndpointName, + } + + // Set new values if set by user. + if c.NewName.WasSet { + input.NewName = &c.NewName.Value + } + + if c.Address.WasSet { + input.Address = &c.Address.Value + } + + if c.Port.WasSet { + input.Port = &c.Port.Value + } + + if c.UseTLS.WasSet { + input.UseTLS = fastly.ToPointer(fastly.Compatibool(c.UseTLS.Value)) + } + + if c.TLSCACert.WasSet { + input.TLSCACert = &c.TLSCACert.Value + } + + if c.TLSHostname.WasSet { + input.TLSHostname = &c.TLSHostname.Value + } + + if c.TLSClientCert.WasSet { + input.TLSClientCert = &c.TLSClientCert.Value + } + + if c.TLSClientKey.WasSet { + input.TLSClientKey = &c.TLSClientKey.Value + } + + if c.Token.WasSet { + input.Token = &c.Token.Value + } + + if c.Format.WasSet { + input.Format = fastly.ToPointer(argparser.Content(c.Format.Value)) + } + + if c.FormatVersion.WasSet { + input.FormatVersion = &c.FormatVersion.Value + } + + if c.MessageType.WasSet { + input.MessageType = &c.MessageType.Value + } + + if c.ResponseCondition.WasSet { + input.ResponseCondition = &c.ResponseCondition.Value + } + + if c.Placement.WasSet { + input.Placement = &c.Placement.Value + } + + return &input, nil +} + +// Exec invokes the application logic for the command. +func (c *UpdateCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.AutoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.ServiceName, + ServiceVersionFlag: c.ServiceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + input, err := c.ConstructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + syslog, err := c.Globals.APIClient.UpdateSyslog(input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + text.Success(out, + "Updated Syslog logging endpoint %s (service %s version %d)", + fastly.ToValue(syslog.Name), + fastly.ToValue(syslog.ServiceID), + fastly.ToValue(syslog.ServiceVersion), + ) + return nil +} diff --git a/pkg/commands/logtail/doc.go b/pkg/commands/logtail/doc.go new file mode 100644 index 000000000..f0a00d37a --- /dev/null +++ b/pkg/commands/logtail/doc.go @@ -0,0 +1,3 @@ +// Package logtail contains commands to inspect and manipulate Fastly streaming +// log data. +package logtail diff --git a/pkg/commands/logtail/root.go b/pkg/commands/logtail/root.go new file mode 100644 index 000000000..8c0a84ed5 --- /dev/null +++ b/pkg/commands/logtail/root.go @@ -0,0 +1,664 @@ +package logtail + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "os/signal" + "sort" + "strconv" + "strings" + "syscall" + "time" + + "github.com/tomnomnom/linkheader" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/debug" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// RootCommand is the parent command for all subcommands in this package. +// It should be installed under the primary root command. +type RootCommand struct { + argparser.Base + + Input fastly.CreateManagedLoggingInput + batchCh chan Batch // send batches to output loop + cfg cfg + dieCh chan struct{} // channel to end output/printing + doneCh chan struct{} // channel to signal we've reached the end of the run + hClient *http.Client // TODO: this will go away when GET is in go-fastly + serviceName argparser.OptionalServiceNameID + token string // TODO: this will go away when GET is in go-fastly +} + +// CommandName is the string to be used to invoke this command. +const CommandName = "log-tail" + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent argparser.Registerer, g *global.Data) *RootCommand { + var c RootCommand + c.Globals = g + c.CmdClause = parent.Command(CommandName, "Tail Compute logs") + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + c.CmdClause.Flag("from", "From time, in Unix seconds").Int64Var(&c.cfg.from) + c.CmdClause.Flag("to", "To time, in Unix seconds").Int64Var(&c.cfg.to) + c.CmdClause.Flag("sort-buffer", "Duration of sort buffer for received logs").Default("1s").DurationVar(&c.cfg.sortBuffer) + c.CmdClause.Flag("search-padding", "Time beyond from/to to consider in searches").Default("2s").DurationVar(&c.cfg.searchPadding) + c.CmdClause.Flag("stream", "Output: stdout, stderr, both (default)").StringVar(&c.cfg.stream) + c.CmdClause.Flag("timestamps", "Print timestamps with logs").BoolVar(&c.cfg.printTimestamps) + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, source, flag, err := argparser.ServiceID(c.serviceName, *c.Globals.Manifest, c.Globals.APIClient, c.Globals.ErrLog) + if err != nil { + return err + } + if c.Globals.Verbose() { + argparser.DisplayServiceID(serviceID, flag, source, out) + } + + c.Input.ServiceID = serviceID + + c.Input.Kind = fastly.ManagedLoggingInstanceOutput + endpoint, _ := c.Globals.APIEndpoint() + c.cfg.path = fmt.Sprintf("%s/service/%s/log_stream/managed/instance_output", endpoint, c.Input.ServiceID) + + c.dieCh = make(chan struct{}) + c.batchCh = make(chan Batch) + c.doneCh = make(chan struct{}) + + c.hClient = http.DefaultClient + c.token, _ = c.Globals.Token() + + // Adjust the from/to times if they are + // defined. We adjust the times based on searchPadding. + c.adjustTimes() + + // Enable managed logging if not already enabled. + if err := c.enableManagedLogging(out); err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + failure := make(chan error) + sigs := make(chan os.Signal, 2) + signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP) + + // Start the output loop. + go c.outputLoop(out) + + // Start tailing the logs. + go func() { + failure <- c.tail(out) + }() + + select { + case asyncErr := <-failure: + close(c.dieCh) + return asyncErr + case <-c.doneCh: + return nil + case <-sigs: + close(c.dieCh) + } + + return nil +} + +// Tail starts the virtual tail process. Tail fetches data from the eventbuffer +// API. It hands off the requested logs to the outputloop for the actual +// printing. +func (c *RootCommand) tail(out io.Writer) error { + // Start this with --from and --to if set. + curWindow := c.cfg.from + toWindow := c.cfg.to + + // Start the loop with an initial address to query. + path, err := makeNewPath(c.cfg.path, curWindow, "") + if err != nil { + return err + } + + // lastBatchID keeps the last successfully read Batch.ID in case we need + // re-request on failure. + var lastBatchID string + + for { + // Check to see if we already passed the "to" requirement. + if toWindow != 0 && curWindow > toWindow { + text.Info(out, "Reached window: %v which is newer than the requested 'to': %v", curWindow, toWindow) + // We are done, but we still want printing to finish. + close(c.doneCh) + break + } + + req, err := http.NewRequest(http.MethodGet, path, nil) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + http.MethodGet: path, + }) + return fmt.Errorf("unable to create new request: %w", err) + } + req.Header.Add("Fastly-Key", c.token) + + resp, err := c.doReq(req) + if err != nil { + c.Globals.ErrLog.Add(err) + return fmt.Errorf("unable to execute request: %w", err) + } + + // Check that our request was successful. If the server is + // having trouble, retry after waiting for some time. + if resp.StatusCode != http.StatusOK { + // If the response was a 404, the from time was + // not valid, give them an error stating this and exit. + if resp.StatusCode == http.StatusNotFound && + c.cfg.from != 0 { + return fmt.Errorf("specified 'from' time %d not found, either too far in the past or future", c.cfg.from) + } + + // In an effort to clean up the output, do not print on + // 503's. + if resp.StatusCode != http.StatusServiceUnavailable { + text.Warning(out, "non-200 resp %d", resp.StatusCode) + } + + // Reuse the connection for the retry, or cleanup in the + // case of Exit. + _, _ = io.Copy(io.Discard, resp.Body) + err := resp.Body.Close() + if err != nil { + c.Globals.ErrLog.Add(err) + } + + // Try the response again after a 1 second wait. + if resp.StatusCode/100 == 5 && resp.StatusCode != 501 || + resp.StatusCode == http.StatusTooManyRequests { + time.Sleep(1 * time.Second) + continue + } + + // Failing at this point is unrecoverable. + return fmt.Errorf("unrecoverable error, response code: %d", resp.StatusCode) + } + + // Read and parse response, send batches to the output loop. + scanner := bufio.NewScanner(resp.Body) + + // Use a 10MB buffer for the bufio scanner, as we don't know + // how big some of the responses will be. + const tmb = 10 << 20 + buf := make([]byte, tmb) + scanner.Buffer(buf, tmb) + + for scanner.Scan() { + // Scan one line at a time, and get only one batch + // at a time. + b := scanner.Bytes() + batch, err := parseResponseData(b) + if err != nil { + c.Globals.ErrLog.Add(err) + // We can't parse the response, attempt to + // re-request from the last window & batch. + text.Warning(out, "unable to parse response body: %v", err) + path, err = makeNewPath(path, curWindow, lastBatchID) + if err != nil { + return err + } + continue + } + + // If we got a batch back, there will be an ID. + if batch.ID != "" { + // Record last batchID in case + // anything fails along the way, we + // can re-request. + lastBatchID = batch.ID + // Send batch down batchCh to the output loop. + c.batchCh <- batch + } + } + err = resp.Body.Close() + if err != nil { + c.Globals.ErrLog.Add(err) + } + + if err := scanner.Err(); err != nil { + c.Globals.ErrLog.Add(err) + // ErrUnexpectedEOFs need to be retried, but they + // produce a lot of noise for the user, so don't log. + if err != io.ErrUnexpectedEOF { + text.Warning(out, "error scanning response body: %v", err) + } + + // Something happened in the scanner, re-request the + // current batchID. + path, err = makeNewPath(path, curWindow, lastBatchID) + if err != nil { + return err + } + continue + } + + // Get our next time window to request. + _, next := getLinks(resp.Header) + curWindow, err = getTimeFromLink(next) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Next link": next, + }) + text.Error(out, "error generating window from next link") + } + + // We do NOT want to specify a batchID, as this + // request was successful. + lastBatchID = "" + path, err = makeNewPath(path, curWindow, lastBatchID) + if err != nil { + return err + } + } + return nil +} + +// adjustTimes adjusts the passed in from and to flags based on the +// specified padding. +func (c *RootCommand) adjustTimes() { + if c.cfg.from != 0 { + // Adjust from based on search padding, we want to + // look back further. + c.cfg.from -= int64(c.cfg.searchPadding.Seconds()) + } + + if c.cfg.to != 0 { + // Adjust to based on search padding, we want look forward more. + c.cfg.to += int64(c.cfg.searchPadding.Seconds()) + } +} + +// enableManagedLogging enables managed logging in our API. +func (c *RootCommand) enableManagedLogging(out io.Writer) error { + _, err := c.Globals.APIClient.CreateManagedLogging(&c.Input) + if err != nil && err != fastly.ErrManagedLoggingEnabled { + c.Globals.ErrLog.Add(err) + return err + } + + text.Info(out, "Managed logging enabled on service %s\n\n", c.Input.ServiceID) + return nil +} + +// outputLoop processes the logs out of band from the request/response loop. +func (c *RootCommand) outputLoop(out io.Writer) { + type ( + bufferedLog struct { + reqID string + seq int + } + + receive struct { + when time.Time + highSeq int + } + + logrecv struct { + logs []Log + receives []receive + } + ) + + // Channel for timers to notify they are done buffering. + tdCh := make(chan bufferedLog) + + // Single map to keep all buffered logs by RequestID as + // well recording when logs were received. + logmap := make(map[string]logrecv) + + for { + select { + case <-c.dieCh: + return + case batch := <-c.batchCh: // Got new batch. + // Range through batch logs, for each + // RequestID we create a timer based on the + // highest SequenceNum we got in this batch + // for that RequestID. If a timer already + // exists for the RequestID, we append the new + // time.Now() and high SequenceNum. At most + // there should be one timer per RequestID. + for reqid, logs := range splitByReqID(batch.Logs) { + // Required for use in AfterFunc below. + req := reqid + + // Record highest SequenceNum in this new batch + // for this RequestID + highSeq := highSequence(logs) + + // Whether we have the RequestID or not, we + // append and sort the logs slice. + reqLogs := logmap[req] + reqLogs.logs = append(reqLogs.logs, logs...) + // Sort the current batch of logs by their sequence number. + sort.Slice(reqLogs.logs, + func(i, j int) bool { + return reqLogs.logs[i].SequenceNum < reqLogs.logs[j].SequenceNum + }) + + // Check to see if we already have a timer running or if the current + // high sequence is higher than the one with the timer. + // The timer will always be running on the head of the slice. + // In either case append to the receives slice. + recv := reqLogs.receives + if len(recv) == 0 || recv[0].highSeq < highSeq { + // NOTE: gocritic will warn about appendAssign but we ignore it. + // Because if we try to address it the code fails to work at runtime. + //nolint:gocritic + reqLogs.receives = append(recv, receive{ + when: time.Now(), + highSeq: highSeq, + }) + } + + // In only the empty case, start a new timer + // since this is the head of the slice. + if len(recv) == 0 { + time.AfterFunc(c.cfg.sortBuffer, func() { + tdCh <- bufferedLog{ + reqID: req, + seq: highSeq, + } + }) + } + + // Set the new log and receive info back to the + // logmap for this RequestID. + logmap[req] = reqLogs + } + + case bufdLogs := <-tdCh: // A timer expired for a particular request. + reqID, seq := bufdLogs.reqID, bufdLogs.seq + + // Get the logs for this RequestID and + // find the index of the sequence in our current logs. + reqLogs := logmap[reqID] + idx := findIdxBySeq(reqLogs.logs, seq) + + // Split off the source of this timer, leave + // remaining logs to be printed later. + toPrint, remainingLogs := reqLogs.logs[:idx], reqLogs.logs[idx:] + reqLogs.logs = remainingLogs + c.printLogs(out, toPrint) + + // Special case if we just printed the entire set of + // logs, we remove the keys from the maps and finish. + if len(remainingLogs) == 0 { + delete(logmap, reqID) + break + } + + // Drop the front of the batchReqReceives map and start + // another timer for any remaining recorded sequences. + recv := reqLogs.receives[1:] + reqLogs.receives = recv + + // If anything is left... + if len(recv) > 0 { + // We create a new timer, we subtract + // off time already served from the + // user defined sortBuffer. + time.AfterFunc(c.cfg.sortBuffer-time.Since(recv[0].when), func() { + tdCh <- bufferedLog{ + reqID: reqID, + seq: recv[0].highSeq, + } + }) + } + + // Set the new log and receive info back to the + // logmap for this RequestID. + logmap[reqID] = reqLogs + } + } +} + +// printLogs is a simple printer for Log slices, only printing requested +// streams. +func (c *RootCommand) printLogs(out io.Writer, logs []Log) { + if len(logs) > 0 { + filtered := filterStream(c.cfg.stream, logs) + + for _, l := range filtered { + if c.cfg.printTimestamps { + fmt.Fprint(out, l.RequestStartFromRaw().UTC().Format(time.RFC3339)) + fmt.Fprint(out, " | ") + } + fmt.Fprintln(out, l.String()) + } + } +} + +// doReq runs the http.Request, returning a http.Response or error. +func (c *RootCommand) doReq(req *http.Request) (*http.Response, error) { + ctx, cancel := context.WithCancel(context.Background()) + req = req.WithContext(ctx) + go func() { + select { + case <-ctx.Done(): + case <-c.dieCh: + cancel() + } + }() + + if c.Globals.Flags.Debug { + debug.DumpHTTPRequest(req) + } + resp, err := c.hClient.Do(req) + if c.Globals.Flags.Debug { + debug.DumpHTTPResponse(resp) + } + return resp, err +} + +type ( + // cfg holds the configuration parameters passed in through + // command line arguments. + cfg struct { + // path is the full path to fetch + path string + + // from is how far in the past to start showing logs. + from int64 + + // to is when to get logs until. + to int64 + + // printTimestamps is whether to print timestamps with logs. + printTimestamps bool + + // sortBuffer is how long to buffer logs from when the cli + // receives them to when the cli prints them. It will sort + // by RequestID for that buffer period. + sortBuffer time.Duration + // searchPadding is how much of a window on either side of + // from and to to use for searching for the beginning or + // through the end timestamps. + searchPadding time.Duration + // stream specifies which of stdout or stderr or both the + // customer wants to consume. + // Undefined == both stderr and stdout. + stream string + } + + // Log defines the message envelope that the Compute platform wraps the + // user messages in. + Log struct { + // SequenceNum is the message sequence number used to reorder + // messages. + SequenceNum int `json:"sequence_number"` + // RequestTime is the time in microseconds when the request + // was received. + RequestStart int64 `json:"request_start_us"` + // Stream is the Compute stream, either stdout or stderr. + Stream string `json:"stream"` + // RequestID is a UUID representing individual requests to the + // particular Wasm service. + RequestID string `json:"id"` + // Message is the actual message body the user wants printed. + Message string `json:"message"` + } + + // Batch encompasses a batch ID and the logs for this batch. + Batch struct { + ID string `json:"batch_id"` + Logs []Log `json:"logs"` + } +) + +// RequestStartFromRaw return a time.Time object representing the +// RequestStart data. +func (l *Log) RequestStartFromRaw() time.Time { + // RequestTime comes as unix time in microseconds. Convert to + // nanoseconds, then parse with stdlib. + nano := l.RequestStart * 1000 + return time.Unix(0, nano) +} + +// String is used to print a log for the tail output. +func (l *Log) String() string { + // Trim the RequestID for nicer output, it might be a long UUID. + return fmt.Sprintf("%6s | %8.8s | %s", + l.Stream, + l.RequestID, + l.Message) +} + +// makeNewPath generates a new request path based on current +// path, window, and batchID. +func makeNewPath(path string, window int64, batchID string) (string, error) { + basePath, err := url.Parse(path) + if err != nil { + return "", fmt.Errorf("error generating request URL: %w", err) + } + + // Unset anything in the query parameters that might already exist. + basePath.RawQuery = "" + + q := basePath.Query() + if window != 0 { + q.Set("from", strconv.FormatInt(window, 10)) + } + + if batchID != "" { + q.Set("batch_id", batchID) + } + + basePath.RawQuery = q.Encode() + return basePath.String(), nil +} + +// splitByReqID splits slices of logs based on RequestID. +func splitByReqID(in []Log) map[string][]Log { + out := make(map[string][]Log) + for _, l := range in { + out[l.RequestID] = append(out[l.RequestID], l) + } + return out +} + +// parseResponseData returns the batch from a response. +func parseResponseData(data []byte) (Batch, error) { + var batch Batch + reader := bytes.NewReader(data) + d := json.NewDecoder(reader) + + if err := d.Decode(&batch); err != nil && err != io.EOF { + return batch, err + } + + return batch, nil +} + +// filterStream returns only logs that are requested by the stream flag. +func filterStream(stream string, logs []Log) []Log { + // If unset, do not filter out any logs. + if stream == "" { + return logs + } + + var out []Log + for _, l := range logs { + // If the stream matches what they wanted, keep it. + if stream == l.Stream { + out = append(out, l) + } + } + return out +} + +// getTimeFromLink splits a link header format, returning +// the time. +func getTimeFromLink(link string) (int64, error) { + s := strings.SplitN(link, "=", 2)[1] + return strconv.ParseInt(s, 10, 64) +} + +// getLinks returns the prev and next links from a header. +func getLinks(head http.Header) (prev, next string) { + links := linkheader.ParseMultiple(head["Link"]) + for _, link := range links { + switch link.Rel { + case "prev": + prev = link.URL + case "next": + next = link.URL + } + } + return prev, next +} + +// findIdxBySeq returns the slice index after the +// SequenceNum we are searching for. +func findIdxBySeq(logs []Log, seq int) int { + for i, v := range logs { + if v.SequenceNum > seq { + return i + } + } + return len(logs) +} + +// highSequence returns the highest SequenceNum +// in a slice of logs. +func highSequence(logs []Log) int { + var maximum int + for _, l := range logs { + if l.SequenceNum > maximum { + maximum = l.SequenceNum + } + } + return maximum +} diff --git a/pkg/logs/tail_test.go b/pkg/commands/logtail/tail_test.go similarity index 99% rename from pkg/logs/tail_test.go rename to pkg/commands/logtail/tail_test.go index 3926628a3..e38560863 100644 --- a/pkg/logs/tail_test.go +++ b/pkg/commands/logtail/tail_test.go @@ -1,4 +1,4 @@ -package logs +package logtail import ( "net/http" @@ -33,7 +33,7 @@ func TestAdjustTimes(t *testing.T) { exp: cfg{searchPadding: dur}, }, } { - c := TailCommand{cfg: test.in} + c := RootCommand{cfg: test.in} c.adjustTimes() if equal := reflect.DeepEqual(test.exp, c.cfg); !equal { t.Errorf("#%d: adjustTimes mismatch: got: %#+v want: %#+v", i, c.cfg, test.exp) @@ -296,7 +296,6 @@ func TestSplitOnIdx(t *testing.T) { if len(right) != test.expright { t.Errorf("#%d: exp: %d != got: %d", i, test.expright, len(right)) } - } } diff --git a/pkg/logs/testdata/response.json b/pkg/commands/logtail/testdata/response.json similarity index 100% rename from pkg/logs/testdata/response.json rename to pkg/commands/logtail/testdata/response.json diff --git a/pkg/commands/objectstorage/accesskeys/accesskeys_test.go b/pkg/commands/objectstorage/accesskeys/accesskeys_test.go new file mode 100644 index 000000000..7cdb6f930 --- /dev/null +++ b/pkg/commands/objectstorage/accesskeys/accesskeys_test.go @@ -0,0 +1,312 @@ +package accesskeys_test + +import ( + "bytes" + "fmt" + "io" + "net/http" + "strings" + "testing" + + root "github.com/fastly/cli/pkg/commands/objectstorage" + sub "github.com/fastly/cli/pkg/commands/objectstorage/accesskeys" + fstfmt "github.com/fastly/cli/pkg/fmt" + "github.com/fastly/cli/pkg/testutil" + "github.com/fastly/go-fastly/v10/fastly/objectstorage/accesskeys" +) + +const ( + akID = "accessKeyId" + akSecret = "accessKeySecret" + akDescription = "accessKeyDescription" + akPermission = "read-only-objects" + akBucket1 = "bucket1" + akBucket2 = "bucket2" +) + +var ak = accesskeys.AccessKey{ + AccessKeyID: akID, + SecretKey: akSecret, + Description: akDescription, + Permission: akPermission, + Buckets: []string{akBucket1, akBucket2}, + CreatedAt: testutil.Date, +} + +func TestAccessKeysCreate(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --description flag", + Args: fmt.Sprintf("--permission %s", akPermission), + WantError: "error parsing arguments: required flag --description not provided", + }, + { + Name: "validate missing --permission flag", + Args: fmt.Sprintf("--description %s", akDescription), + WantError: "error parsing arguments: required flag --permission not provided", + }, + { + Name: "validate internal server error", + Args: fmt.Sprintf("--description %s --permission %s", akDescription, akPermission), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusInternalServerError, + Status: http.StatusText(http.StatusInternalServerError), + }, + }, + }, + WantError: "500 - Internal Server Error", + }, + { + Name: "validate API success", + Args: fmt.Sprintf("--description %s --permission %s --bucket %s --bucket %s", akDescription, akPermission, akBucket1, akBucket2), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(ak)))), + }, + }, + }, + WantOutput: fstfmt.Success("Created access key (id: %s, secret: %s)", akID, ak.SecretKey), + }, + { + Name: "validate optional --json flag", + Args: fmt.Sprintf("--description %s --permission %s --json", akDescription, akPermission), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(ak))), + }, + }, + }, + WantOutput: fstfmt.EncodeJSON(ak), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "create"}, scenarios) +} + +func TestAccessKeysDelete(t *testing.T) { + const accessKeyID = "accessKeyID" + + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --ak-id flag", + Args: "", + WantError: "error parsing arguments: required flag --ak-id not provided", + }, + { + Name: "validate bad request", + Args: "--ak-id bar", + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusBadRequest, + Status: http.StatusText(http.StatusBadRequest), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(` + { + "title": "invalid Acess Key ID", + "status": 400 + } + `))), + }, + }, + }, + WantError: "400 - Bad Request", + }, + { + Name: "validate API success", + Args: fmt.Sprintf("--ak-id %s", accessKeyID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusNoContent, + Status: http.StatusText(http.StatusNoContent), + }, + }, + }, + WantOutput: fstfmt.Success("Deleted access key (id: %s)", accessKeyID), + }, + { + Name: "validate optional --json flag", + Args: fmt.Sprintf("--ak-id %s --json", accessKeyID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusNoContent, + Status: http.StatusText(http.StatusNoContent), + }, + }, + }, + WantOutput: fstfmt.JSON(`{"id": %q, "deleted": true}`, accessKeyID), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "delete"}, scenarios) +} + +func TestAccessKeysGet(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --ak-id flag", + Args: "", + WantError: "error parsing arguments: required flag --ak-id not provided", + }, + { + Name: "validate bad request", + Args: "--ak-id baz", + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusBadRequest, + Status: http.StatusText(http.StatusBadRequest), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(` + { + "title": "invalid Acess Key ID", + "status": 400 + } + `))), + }, + }, + }, + WantError: "400 - Bad Request", + }, + { + Name: "validate API success", + Args: fmt.Sprintf("--ak-id %s", akID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(ak)))), + }, + }, + }, + WantOutput: akString, + }, + { + Name: "validate optional --json flag", + Args: fmt.Sprintf("--ak-id %s --json", akID), + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader((testutil.GenJSON(ak)))), + }, + }, + }, + WantOutput: fstfmt.EncodeJSON(ak), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "get"}, scenarios) +} + +func TestAccessKeysList(t *testing.T) { + acesskeysobject := accesskeys.AccessKeys{ + Data: []accesskeys.AccessKey{ + { + AccessKeyID: "foo", + SecretKey: "bar", + Description: "bat", + Permission: akPermission, + }, + { + AccessKeyID: "foobar", + SecretKey: "baz", + Description: "bizz", + Permission: akPermission, + }, + }, + Meta: accesskeys.MetaAccessKeys{}, + } + + scenarios := []testutil.CLIScenario{ + { + Name: "validate internal server error", + Args: "", + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusInternalServerError, + Status: http.StatusText(http.StatusInternalServerError), + }, + }, + }, + WantError: "500 - Internal Server Error", + }, + { + Name: "validate API success (zero access keys)", + Args: "", + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(accesskeys.AccessKeys{ + Data: []accesskeys.AccessKey{}, + Meta: accesskeys.MetaAccessKeys{}, + }))), + }, + }, + }, + WantOutput: zeroListAccessKeysString, + }, + { + Name: "validate API success", + Args: "", + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(acesskeysobject))), + }, + }, + }, + WantOutput: listAccessKeysString, + }, + { + Name: "validate optional --json flag", + Args: "--json", + Client: &http.Client{ + Transport: &testutil.MockRoundTripper{ + Response: &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(bytes.NewReader(testutil.GenJSON(acesskeysobject))), + }, + }, + }, + WantOutput: fstfmt.EncodeJSON(acesskeysobject), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "list-access-keys"}, scenarios) +} + +var akString = strings.TrimSpace(` +ID: accessKeyId +Secret: accessKeySecret +Description: accessKeyDescription +Permission: read-only-objects +Buckets: [bucket1 bucket2] +Created (UTC): 2021-06-15 23:00 +`) + "\n" + +var listAccessKeysString = strings.TrimSpace(` +ID Secret Description Permssion Buckets Created At +foo bar bat read-only-objects [] 0001-01-01 00:00:00 +0000 UTC +foobar baz bizz read-only-objects [] 0001-01-01 00:00:00 +0000 UTC +`) + "\n" + +var zeroListAccessKeysString = strings.TrimSpace(` +ID Secret Description Permssion Buckets Created At +`) + "\n" diff --git a/pkg/commands/objectstorage/accesskeys/create.go b/pkg/commands/objectstorage/accesskeys/create.go new file mode 100644 index 000000000..80346cf58 --- /dev/null +++ b/pkg/commands/objectstorage/accesskeys/create.go @@ -0,0 +1,77 @@ +package accesskeys + +import ( + "errors" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + "github.com/fastly/go-fastly/v10/fastly/objectstorage/accesskeys" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// CreateCommand calls the Fastly API to create an access key. +type CreateCommand struct { + argparser.Base + argparser.JSONOutput + + // Required. + description string + permission string + + // Optional. + buckets []string +} + +// NewCreateCommand returns a usable command registered under the parent. +func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { + c := CreateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + + c.CmdClause = parent.Command("create", "Create an access key") + + // Required. + c.CmdClause.Flag("description", "Description of the access key").Required().StringVar(&c.description) + c.CmdClause.Flag("permission", "Permissions to be given to the access key").Required().StringVar(&c.permission) + + // Optional. + c.CmdClause.Flag("bucket", "Bucket to be associated with the access key. Set flag multiple times to include multiple buckets").StringsVar(&c.buckets) + c.RegisterFlagBool(c.JSONFlag()) + + return &c +} + +// Exec invokes the application logic for the command. +func (c *CreateCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + fc, ok := c.Globals.APIClient.(*fastly.Client) + if !ok { + return errors.New("failed to convert interface to a fastly client") + } + + accessKey, err := accesskeys.Create(fc, &accesskeys.CreateInput{ + Description: &c.description, + Permission: &c.permission, + Buckets: &c.buckets, + }) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if ok, err := c.WriteJSON(out, accessKey); ok { + return err + } + + text.Success(out, "Created access key (id: %s, secret: %s)", accessKey.AccessKeyID, accessKey.SecretKey) + return nil +} diff --git a/pkg/commands/objectstorage/accesskeys/delete.go b/pkg/commands/objectstorage/accesskeys/delete.go new file mode 100644 index 000000000..25927491b --- /dev/null +++ b/pkg/commands/objectstorage/accesskeys/delete.go @@ -0,0 +1,77 @@ +package accesskeys + +import ( + "errors" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + "github.com/fastly/go-fastly/v10/fastly/objectstorage/accesskeys" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// DeleteCommand calls the Fastly API to delete an access key. +type DeleteCommand struct { + argparser.Base + argparser.JSONOutput + + // Required. + id string +} + +// NewDeleteCommand returns a usable command registered under the parent. +func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { + c := DeleteCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + + c.CmdClause = parent.Command("delete", "Delete an access key") + + // Required. + c.CmdClause.Flag("ak-id", "Access key ID").Required().StringVar(&c.id) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) + + return &c +} + +// Exec invokes the application logic for the command. +func (c *DeleteCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + fc, ok := c.Globals.APIClient.(*fastly.Client) + if !ok { + return errors.New("failed to convert interface to a fastly client") + } + + err := accesskeys.Delete(fc, &accesskeys.DeleteInput{ + AccessKeyID: &c.id, + }) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if c.JSONOutput.Enabled { + o := struct { + ID string `json:"id"` + Deleted bool `json:"deleted"` + }{ + c.id, + true, + } + _, err := c.WriteJSON(out, o) + return err + } + + text.Success(out, "Deleted access key (id: %s)", c.id) + return nil +} diff --git a/pkg/commands/objectstorage/accesskeys/doc.go b/pkg/commands/objectstorage/accesskeys/doc.go new file mode 100644 index 000000000..ff349f21c --- /dev/null +++ b/pkg/commands/objectstorage/accesskeys/doc.go @@ -0,0 +1,2 @@ +// Package accesskeys contains commands to inspect and manipulate access keys. +package accesskeys diff --git a/pkg/commands/objectstorage/accesskeys/get.go b/pkg/commands/objectstorage/accesskeys/get.go new file mode 100644 index 000000000..664a45001 --- /dev/null +++ b/pkg/commands/objectstorage/accesskeys/get.go @@ -0,0 +1,69 @@ +package accesskeys + +import ( + "errors" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + "github.com/fastly/go-fastly/v10/fastly/objectstorage/accesskeys" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// GetCommand calls the Fastly API to get an access key. +type GetCommand struct { + argparser.Base + argparser.JSONOutput + + // Required. + accessKeyID string +} + +// NewGetCommand returns a usable command registered under the parent. +func NewGetCommand(parent argparser.Registerer, g *global.Data) *GetCommand { + c := GetCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + + c.CmdClause = parent.Command("get", "Get an access key") + + // Required. + c.CmdClause.Flag("ak-id", "Access key ID").Required().StringVar(&c.accessKeyID) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) + + return &c +} + +// Exec invokes the application logic for the command. +func (c *GetCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + fc, ok := c.Globals.APIClient.(*fastly.Client) + if !ok { + return errors.New("failed to convert interface to a fastly client") + } + + accessKey, err := accesskeys.Get(fc, &accesskeys.GetInput{ + AccessKeyID: &c.accessKeyID, + }) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if ok, err := c.WriteJSON(out, accessKey); ok { + return err + } + + text.PrintAccessKey(out, accessKey) + return nil +} diff --git a/pkg/commands/objectstorage/accesskeys/list.go b/pkg/commands/objectstorage/accesskeys/list.go new file mode 100644 index 000000000..c0303940f --- /dev/null +++ b/pkg/commands/objectstorage/accesskeys/list.go @@ -0,0 +1,60 @@ +package accesskeys + +import ( + "errors" + "io" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" + "github.com/fastly/go-fastly/v10/fastly" + "github.com/fastly/go-fastly/v10/fastly/objectstorage/accesskeys" +) + +// ListCommand calls the Fastly API to list all access keys. +type ListCommand struct { + argparser.Base + argparser.JSONOutput +} + +// NewListCommand returns a usable command registered under the parent. +func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { + c := ListCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + + c.CmdClause = parent.Command("list-access-keys", "List all access keys") + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) + + return &c +} + +// Exec invokes the application logic for the command. +func (c *ListCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + fc, ok := c.Globals.APIClient.(*fastly.Client) + if !ok { + return errors.New("failed to convert interface to a fastly client") + } + + accessKeys, err := accesskeys.ListAccessKeys(fc) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if ok, err := c.WriteJSON(out, accessKeys); ok { + return err + } + + text.PrintAccessKeyTbl(out, accessKeys.Data) + return nil +} diff --git a/pkg/commands/objectstorage/accesskeys/root.go b/pkg/commands/objectstorage/accesskeys/root.go new file mode 100644 index 000000000..982cb0fe6 --- /dev/null +++ b/pkg/commands/objectstorage/accesskeys/root.go @@ -0,0 +1,31 @@ +package accesskeys + +import ( + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" +) + +// RootCommand is the parent command for all subcommands in this package. +// It should be installed under the primary root command. +type RootCommand struct { + argparser.Base + // no flags +} + +// CommandName is the string to be used to invoke this command. +const CommandName = "access-keys" + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent argparser.Registerer, g *global.Data) *RootCommand { + var c RootCommand + c.Globals = g + c.CmdClause = parent.Command(CommandName, "Manipulate Fastly access keys") + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { + panic("unreachable") +} diff --git a/pkg/commands/objectstorage/doc.go b/pkg/commands/objectstorage/doc.go new file mode 100644 index 000000000..9af77f8fc --- /dev/null +++ b/pkg/commands/objectstorage/doc.go @@ -0,0 +1,2 @@ +// Package objectstorage contains commands to inspect and manipulate stored objects. +package objectstorage diff --git a/pkg/commands/objectstorage/root.go b/pkg/commands/objectstorage/root.go new file mode 100644 index 000000000..020c660c8 --- /dev/null +++ b/pkg/commands/objectstorage/root.go @@ -0,0 +1,31 @@ +package objectstorage + +import ( + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" +) + +// RootCommand is the parent command for all subcommands in this package. +// It should be installed under the primary root command. +type RootCommand struct { + argparser.Base + // no flags +} + +// CommandName is the string to be used to invoke this command. +const CommandName = "object-storage" + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent argparser.Registerer, g *global.Data) *RootCommand { + var c RootCommand + c.Globals = g + c.CmdClause = parent.Command(CommandName, "Manage object storage") + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { + panic("unreachable") +} diff --git a/pkg/commands/pop/doc.go b/pkg/commands/pop/doc.go new file mode 100644 index 000000000..78109b69b --- /dev/null +++ b/pkg/commands/pop/doc.go @@ -0,0 +1,2 @@ +// Package pop contains commands to inspect and manipulate Fastly POP data. +package pop diff --git a/pkg/commands/pop/pop_test.go b/pkg/commands/pop/pop_test.go new file mode 100644 index 000000000..814b93766 --- /dev/null +++ b/pkg/commands/pop/pop_test.go @@ -0,0 +1,45 @@ +package pop_test + +import ( + "bytes" + "io" + "testing" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/app" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" +) + +func TestAllDatacenters(t *testing.T) { + var stdout bytes.Buffer + args := testutil.SplitArgs("pops") + api := mock.API{ + AllDatacentersFn: func() ([]fastly.Datacenter, error) { + return []fastly.Datacenter{ + { + Name: fastly.ToPointer("Foobar"), + Code: fastly.ToPointer("FBR"), + Group: fastly.ToPointer("Bar"), + Shield: fastly.ToPointer("Baz"), + Coordinates: &fastly.Coordinates{ + Latitude: fastly.ToPointer(float64(1)), + Longitude: fastly.ToPointer(float64(2)), + X: fastly.ToPointer(float64(3)), + Y: fastly.ToPointer(float64(4)), + }, + }, + }, nil + }, + } + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(args, &stdout) + opts.APIClientFactory = mock.APIClient(api) + return opts, nil + } + err := app.Run(args, nil) + testutil.AssertNoError(t, err) + testutil.AssertString(t, "\nNAME CODE GROUP SHIELD COORDINATES\nFoobar FBR Bar Baz {Latitude:1 Longitude:2 X:3 Y:4}\n", stdout.String()) +} diff --git a/pkg/commands/pop/root.go b/pkg/commands/pop/root.go new file mode 100644 index 000000000..c8e27f6b1 --- /dev/null +++ b/pkg/commands/pop/root.go @@ -0,0 +1,67 @@ +package pop + +import ( + "fmt" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// RootCommand is the parent command for all subcommands in this package. +// It should be installed under the primary root command. +type RootCommand struct { + argparser.Base +} + +// CommandName is the string to be used to invoke this command. +const CommandName = "pops" + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent argparser.Registerer, g *global.Data) *RootCommand { + var c RootCommand + c.Globals = g + c.CmdClause = parent.Command(CommandName, "List Fastly datacenters") + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, out io.Writer) error { + dcs, err := c.Globals.APIClient.AllDatacenters() + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + text.Break(out) + t := text.NewTable(out) + t.AddHeader("NAME", "CODE", "GROUP", "SHIELD", "COORDINATES") + for _, dc := range dcs { + t.AddLine( + fastly.ToValue(dc.Name), + fastly.ToValue(dc.Code), + fastly.ToValue(dc.Group), + fastly.ToValue(dc.Shield), + Coordinates(dc.Coordinates), + ) + } + t.Print() + return nil +} + +// Coordinates returns a stringified object of coordinate data. +func Coordinates(c *fastly.Coordinates) string { + if c != nil { + return fmt.Sprintf( + `{Latitude:%v Longitude:%v X:%v Y:%v}`, + fastly.ToValue(c.Latitude), + fastly.ToValue(c.Longitude), + fastly.ToValue(c.X), + fastly.ToValue(c.Y), + ) + } + return "" +} diff --git a/pkg/commands/products/doc.go b/pkg/commands/products/doc.go new file mode 100644 index 000000000..944be33a9 --- /dev/null +++ b/pkg/commands/products/doc.go @@ -0,0 +1,2 @@ +// Package products contains commands to inspect and manipulate Fastly products. +package products diff --git a/pkg/commands/products/products_test.go b/pkg/commands/products/products_test.go new file mode 100644 index 000000000..291622f16 --- /dev/null +++ b/pkg/commands/products/products_test.go @@ -0,0 +1,138 @@ +package products_test + +import ( + "testing" + + "github.com/fastly/go-fastly/v10/fastly" + + root "github.com/fastly/cli/pkg/commands/products" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" +) + +func TestProductEnablement(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing Service ID", + WantError: "failed to identify Service ID: error reading service: no service ID found", + }, + { + Name: "validate invalid enable/disable flag combo", + Args: "--enable fanout --disable fanout", + WantError: "invalid flag combination: --enable and --disable", + }, + { + Name: "validate API error for product status", + API: mock.API{ + GetProductFn: func(_ *fastly.ProductEnablementInput) (*fastly.ProductEnablement, error) { + return nil, testutil.Err + }, + }, + Args: "--service-id 123", + WantOutput: `PRODUCT ENABLED +bot_management false +brotli_compression false +domain_inspector false +fanout false +image_optimizer false +log_explorer_insights false +origin_inspector false +websockets false +`, + }, + { + Name: "validate API success for product status", + API: mock.API{ + GetProductFn: func(_ *fastly.ProductEnablementInput) (*fastly.ProductEnablement, error) { + return nil, nil + }, + }, + Args: "--service-id 123", + WantOutput: `PRODUCT ENABLED +bot_management true +brotli_compression true +domain_inspector true +fanout true +image_optimizer true +log_explorer_insights true +origin_inspector true +websockets true +`, + }, + { + Name: "validate flag parsing error for enabling product", + Args: "--service-id 123 --enable foo", + WantError: "error parsing arguments: enum value must be one of bot_management,brotli_compression,domain_inspector,fanout,image_optimizer,log_explorer_insights,origin_inspector,websockets, got 'foo'", + }, + { + Name: "validate flag parsing error for disabling product", + Args: "--service-id 123 --disable foo", + WantError: "error parsing arguments: enum value must be one of bot_management,brotli_compression,domain_inspector,fanout,image_optimizer,log_explorer_insights,origin_inspector,websockets, got 'foo'", + }, + { + Name: "validate success for enabling product", + API: mock.API{ + EnableProductFn: func(_ *fastly.ProductEnablementInput) (*fastly.ProductEnablement, error) { + return nil, nil + }, + }, + Args: "--service-id 123 --enable brotli_compression", + WantOutput: "SUCCESS: Successfully enabled product 'brotli_compression'", + }, + { + Name: "validate success for disabling product", + API: mock.API{ + DisableProductFn: func(_ *fastly.ProductEnablementInput) error { + return nil + }, + }, + Args: "--service-id 123 --disable brotli_compression", + WantOutput: "SUCCESS: Successfully disabled product 'brotli_compression'", + }, + { + Name: "validate invalid json/verbose flag combo", + Args: "--service-id 123 --json --verbose", + WantError: "invalid flag combination, --verbose and --json", + }, + { + Name: "validate API error for product status with --json output", + API: mock.API{ + GetProductFn: func(_ *fastly.ProductEnablementInput) (*fastly.ProductEnablement, error) { + return nil, testutil.Err + }, + }, + Args: "--service-id 123 --json", + WantOutput: `{ + "bot_management": false, + "brotli_compression": false, + "domain_inspector": false, + "fanout": false, + "image_optimizer": false, + "log_explorer_insights": false, + "origin_inspector": false, + "websockets": false +}`, + }, + { + Name: "validate API success for product status with --json output", + API: mock.API{ + GetProductFn: func(_ *fastly.ProductEnablementInput) (*fastly.ProductEnablement, error) { + return nil, nil + }, + }, + Args: "--service-id 123 --json", + WantOutput: `{ + "bot_management": true, + "brotli_compression": true, + "domain_inspector": true, + "fanout": true, + "image_optimizer": true, + "log_explorer_insights": true, + "origin_inspector": true, + "websockets": true +}`, + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName}, scenarios) +} diff --git a/pkg/commands/products/root.go b/pkg/commands/products/root.go new file mode 100644 index 000000000..0d472e602 --- /dev/null +++ b/pkg/commands/products/root.go @@ -0,0 +1,216 @@ +package products + +import ( + "errors" + "fmt" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// RootCommand is the parent command for all subcommands in this package. +// It should be installed under the primary root command. +type RootCommand struct { + argparser.Base + argparser.JSONOutput + + disableProduct string + enableProduct string + serviceName argparser.OptionalServiceNameID +} + +// ProductEnablementOptions is a list of products that can be enabled/disabled. +var ProductEnablementOptions = []string{ + "bot_management", + "brotli_compression", + "domain_inspector", + "fanout", + "image_optimizer", + "log_explorer_insights", + "origin_inspector", + "websockets", +} + +// CommandName is the string to be used to invoke this command. +const CommandName = "products" + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent argparser.Registerer, g *global.Data) *RootCommand { + var c RootCommand + c.Globals = g + c.CmdClause = parent.Command(CommandName, "Enable, disable, and check the enablement status of products") + + // Optional. + c.CmdClause.Flag("disable", "Disable product").HintOptions(ProductEnablementOptions...).EnumVar(&c.disableProduct, ProductEnablementOptions...) + c.CmdClause.Flag("enable", "Enable product").HintOptions(ProductEnablementOptions...).EnumVar(&c.enableProduct, ProductEnablementOptions...) + c.RegisterFlagBool(c.JSONFlag()) // --json + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, out io.Writer) error { + ac := c.Globals.APIClient + + if c.enableProduct != "" && c.disableProduct != "" { + return fsterr.ErrInvalidEnableDisableFlagCombo + } + + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + serviceID, _, _, err := argparser.ServiceID(c.serviceName, *c.Globals.Manifest, c.Globals.APIClient, c.Globals.ErrLog) + if err != nil { + return fmt.Errorf("failed to identify Service ID: %w", err) + } + + if c.enableProduct != "" { + p := identifyProduct(c.enableProduct) + if p == fastly.ProductUndefined { + return errors.New("unrecognised product") + } + if _, err := ac.EnableProduct(&fastly.ProductEnablementInput{ + ProductID: p, + ServiceID: serviceID, + }); err != nil { + return fmt.Errorf("failed to enable product '%s': %w", c.enableProduct, err) + } + text.Success(out, "Successfully enabled product '%s'", c.enableProduct) + return nil + } + + if c.disableProduct != "" { + p := identifyProduct(c.disableProduct) + if p == fastly.ProductUndefined { + return errors.New("unrecognised product") + } + if err := ac.DisableProduct(&fastly.ProductEnablementInput{ + ProductID: p, + ServiceID: serviceID, + }); err != nil { + return fmt.Errorf("failed to disable product '%s': %w", c.disableProduct, err) + } + text.Success(out, "Successfully disabled product '%s'", c.disableProduct) + return nil + } + + ps := ProductStatus{} + + if _, err = ac.GetProduct(&fastly.ProductEnablementInput{ + ProductID: fastly.ProductBotManagement, + ServiceID: serviceID, + }); err == nil { + ps.BotManagement = true + } + if _, err = ac.GetProduct(&fastly.ProductEnablementInput{ + ProductID: fastly.ProductBrotliCompression, + ServiceID: serviceID, + }); err == nil { + ps.BrotliCompression = true + } + if _, err = ac.GetProduct(&fastly.ProductEnablementInput{ + ProductID: fastly.ProductDomainInspector, + ServiceID: serviceID, + }); err == nil { + ps.DomainInspector = true + } + if _, err = ac.GetProduct(&fastly.ProductEnablementInput{ + ProductID: fastly.ProductFanout, + ServiceID: serviceID, + }); err == nil { + ps.Fanout = true + } + if _, err = ac.GetProduct(&fastly.ProductEnablementInput{ + ProductID: fastly.ProductImageOptimizer, + ServiceID: serviceID, + }); err == nil { + ps.ImageOptimizer = true + } + if _, err = ac.GetProduct(&fastly.ProductEnablementInput{ + ProductID: fastly.ProductLogExplorerInsights, + ServiceID: serviceID, + }); err == nil { + ps.LogExplorerInsights = true + } + if _, err = ac.GetProduct(&fastly.ProductEnablementInput{ + ProductID: fastly.ProductOriginInspector, + ServiceID: serviceID, + }); err == nil { + ps.OriginInspector = true + } + if _, err = ac.GetProduct(&fastly.ProductEnablementInput{ + ProductID: fastly.ProductWebSockets, + ServiceID: serviceID, + }); err == nil { + ps.WebSockets = true + } + + if ok, err := c.WriteJSON(out, ps); ok { + return err + } + + t := text.NewTable(out) + t.AddHeader("PRODUCT", "ENABLED") + t.AddLine(fastly.ProductBotManagement, ps.BotManagement) + t.AddLine(fastly.ProductBrotliCompression, ps.BrotliCompression) + t.AddLine(fastly.ProductDomainInspector, ps.DomainInspector) + t.AddLine(fastly.ProductFanout, ps.Fanout) + t.AddLine(fastly.ProductImageOptimizer, ps.ImageOptimizer) + t.AddLine(fastly.ProductLogExplorerInsights, ps.LogExplorerInsights) + t.AddLine(fastly.ProductOriginInspector, ps.OriginInspector) + t.AddLine(fastly.ProductWebSockets, ps.WebSockets) + t.Print() + return nil +} + +func identifyProduct(product string) fastly.Product { + switch product { + case "bot_management": + return fastly.ProductBotManagement + case "brotli_compression": + return fastly.ProductBrotliCompression + case "domain_inspector": + return fastly.ProductDomainInspector + case "fanout": + return fastly.ProductFanout + case "image_optimizer": + return fastly.ProductImageOptimizer + case "log_explorer_insights": + return fastly.ProductLogExplorerInsights + case "origin_inspector": + return fastly.ProductOriginInspector + case "websockets": + return fastly.ProductWebSockets + default: + return fastly.ProductUndefined + } +} + +// ProductStatus indicates the status for each product. +type ProductStatus struct { + BotManagement bool `json:"bot_management"` + BrotliCompression bool `json:"brotli_compression"` + DomainInspector bool `json:"domain_inspector"` + Fanout bool `json:"fanout"` + ImageOptimizer bool `json:"image_optimizer"` + LogExplorerInsights bool `json:"log_explorer_insights"` + OriginInspector bool `json:"origin_inspector"` + WebSockets bool `json:"websockets"` +} diff --git a/pkg/commands/profile/create.go b/pkg/commands/profile/create.go new file mode 100644 index 000000000..10bb1c64b --- /dev/null +++ b/pkg/commands/profile/create.go @@ -0,0 +1,282 @@ +package profile + +import ( + "errors" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "strings" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/api" + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/sso" + "github.com/fastly/cli/pkg/config" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/profile" + "github.com/fastly/cli/pkg/text" +) + +// CreateCommand represents a Kingpin command. +type CreateCommand struct { + argparser.Base + ssoCmd *sso.RootCommand + + automationToken bool + profile string + sso bool +} + +// NewCreateCommand returns a new command registered in the parent. +func NewCreateCommand(parent argparser.Registerer, g *global.Data, ssoCmd *sso.RootCommand) *CreateCommand { + var c CreateCommand + c.Globals = g + c.ssoCmd = ssoCmd + c.CmdClause = parent.Command("create", "Create user profile") + c.CmdClause.Arg("profile", "Profile to create (default 'user')").Default(profile.DefaultName).Short('p').StringVar(&c.profile) + c.CmdClause.Flag("automation-token", "Expected input will be an 'automation token' instead of a 'user token'").BoolVar(&c.automationToken) + c.CmdClause.Flag("sso", "Create an SSO-based token").BoolVar(&c.sso) + return &c +} + +// Exec implements the command interface. +func (c *CreateCommand) Exec(in io.Reader, out io.Writer) (err error) { + if c.sso && c.automationToken { + return fsterr.ErrInvalidProfileSSOCombo + } + + if c.Globals.Verbose() { + text.Break(out) + } + text.Output(out, "Creating profile '%s'", c.profile) + + if profile.Exist(c.profile, c.Globals.Config.Profiles) { + return fsterr.RemediationError{ + Inner: fmt.Errorf("profile '%s' already exists", c.profile), + Remediation: "Re-run the command and pass a different value for the 'profile' argument.", + } + } + + // FIXME: Put back messaging once SSO is GA. + // if !c.sso { + // text.Info(out, "When creating a profile you can either paste in a long-lived token or allow the Fastly CLI to generate a short-lived token that can be automatically refreshed. To create an SSO-based token, pass the `--sso` flag: `fastly profile create --sso`.") + // text.Break(out) + // } + + // The Default status of a new profile should always be true unless there is + // an existing profile already set to be the default. In the latter scenario + // we should prompt the user to see if the new profile they're creating needs + // to become the new default. + makeDefault := true + if _, p := profile.Default(c.Globals.Config.Profiles); p != nil && !c.Globals.Flags.AutoYes && !c.Globals.Flags.NonInteractive { + makeDefault, err = c.promptForDefault(in, out) + if err != nil { + return err + } + } + text.Break(out) + + if c.sso { + // IMPORTANT: We need to set profile fields for `sso` command. + // + // This is so the `sso` command will use this information to create + // a new 'non-default' profile. + c.ssoCmd.InvokedFromProfileCreate = true + c.ssoCmd.ProfileCreateName = c.profile + c.ssoCmd.ProfileDefault = makeDefault + + err = c.ssoCmd.Exec(in, out) + if err != nil { + return fmt.Errorf("failed to authenticate: %w", err) + } + text.Break(out) + } else { + if err := c.staticTokenFlow(makeDefault, in, out); err != nil { + return err + } + } + + if err := c.persistCfg(); err != nil { + return err + } + + displayCfgPath(c.Globals.ConfigPath, out) + text.Success(out, "Profile '%s' created", c.profile) + return nil +} + +// staticTokenFlow initialises the token flow for a non-OAuth token. +func (c *CreateCommand) staticTokenFlow(makeDefault bool, in io.Reader, out io.Writer) error { + token, err := promptForToken(in, out, c.Globals.ErrLog) + if err != nil { + return err + } + text.Break(out) + + endpoint, _ := c.Globals.APIEndpoint() + + spinner, err := text.NewSpinner(out) + if err != nil { + return err + } + + defer func() { + if err != nil { + c.Globals.ErrLog.Add(err) + } + }() + + email, err := c.validateToken(token, endpoint, spinner) + if err != nil { + return err + } + + return c.updateInMemCfg(email, token, endpoint, makeDefault, spinner) +} + +func promptForToken(in io.Reader, out io.Writer, errLog fsterr.LogInterface) (string, error) { + text.Output(out, "An API token is used to authenticate requests to the Fastly API. To create a token, visit https://manage.fastly.com/account/personal/tokens\n\n") + token, err := text.InputSecure(out, text.Prompt("Fastly API token: "), in, validateTokenNotEmpty) + if err != nil { + errLog.Add(err) + return "", err + } + text.Break(out) + return token, nil +} + +func validateTokenNotEmpty(s string) error { + if s == "" { + return ErrEmptyToken + } + return nil +} + +// ErrEmptyToken is returned when a user tries to supply an empty string as a +// token in the terminal prompt. +var ErrEmptyToken = errors.New("token cannot be empty") + +// validateToken ensures the token can be used to acquire user data. +func (c *CreateCommand) validateToken(token, endpoint string, spinner text.Spinner) (string, error) { + var ( + client api.Interface + err error + t *fastly.Token + ) + err = spinner.Process("Validating token", func(_ *text.SpinnerWrapper) error { + client, err = c.Globals.APIClientFactory(token, endpoint, c.Globals.Flags.Debug) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Endpoint": endpoint, + }) + return fmt.Errorf("error regenerating Fastly API client: %w", err) + } + + t, err = client.GetTokenSelf() + if err != nil { + c.Globals.ErrLog.Add(err) + return fmt.Errorf("error validating token: %w", err) + } + return nil + }) + if err != nil { + return "", err + } + if c.automationToken { + return fmt.Sprintf("Automation Token (%s)", fastly.ToValue(t.TokenID)), nil + } + + var user *fastly.User + err = spinner.Process("Getting user data", func(_ *text.SpinnerWrapper) error { + user, err = client.GetUser(&fastly.GetUserInput{ + UserID: fastly.ToValue(t.UserID), + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "User ID": t.UserID, + }) + return fsterr.RemediationError{ + Inner: fmt.Errorf("error fetching token user: %w", err), + Remediation: "If providing an 'automation token', retry the command with the `--automation-token` flag set.", + } + } + return nil + }) + if err != nil { + return "", err + } + return fastly.ToValue(user.Login), nil +} + +// updateInMemCfg persists the updated configuration data in-memory. +func (c *CreateCommand) updateInMemCfg(email, token, endpoint string, makeDefault bool, spinner text.Spinner) error { + return spinner.Process("Persisting configuration", func(_ *text.SpinnerWrapper) error { + c.Globals.Config.Fastly.APIEndpoint = endpoint + + if c.Globals.Config.Profiles == nil { + c.Globals.Config.Profiles = make(config.Profiles) + } + c.Globals.Config.Profiles[c.profile] = &config.Profile{ + Default: makeDefault, + Email: email, + Token: token, + } + + // If the user wants the newly created profile to be their new default, then + // we'll call SetDefault for its side effect of resetting all other profiles + // to have their Default field set to false. + if makeDefault { + if p, ok := profile.SetDefault(c.profile, c.Globals.Config.Profiles); ok { + c.Globals.Config.Profiles = p + } + } + return nil + }) +} + +func (c *CreateCommand) persistCfg() error { + // TODO: The following directory checks should be encapsulated by the + // File.Write() method as this chunk of code is duplicated in various places. + // Consider consolidating with pkg/filesystem/directory.go + // This function is itself duplicated in pkg/commands/profile/update.go + dir := filepath.Dir(c.Globals.ConfigPath) + fi, err := os.Stat(dir) + switch { + case err == nil && !fi.IsDir(): + return fmt.Errorf("config file path %s isn't a directory", dir) + case err != nil && errors.Is(err, fs.ErrNotExist): + if err := os.MkdirAll(dir, config.DirectoryPermissions); err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Directory": dir, + "Permissions": config.DirectoryPermissions, + }) + return fmt.Errorf("error creating config file directory: %w", err) + } + } + + if err := c.Globals.Config.Write(c.Globals.ConfigPath); err != nil { + c.Globals.ErrLog.Add(err) + return fmt.Errorf("error saving config file: %w", err) + } + + return nil +} + +func displayCfgPath(path string, out io.Writer) { + filePath := strings.ReplaceAll(path, " ", `\ `) + text.Break(out) + text.Description(out, "You can find your configuration file at", filePath) +} + +func (c *CreateCommand) promptForDefault(in io.Reader, out io.Writer) (bool, error) { + cont, err := text.AskYesNo(out, "\nSet this profile to be your default? [y/N] ", in) + if err != nil { + c.Globals.ErrLog.Add(err) + return false, err + } + return cont, nil +} diff --git a/pkg/commands/profile/delete.go b/pkg/commands/profile/delete.go new file mode 100644 index 000000000..e2aecc2e2 --- /dev/null +++ b/pkg/commands/profile/delete.go @@ -0,0 +1,47 @@ +package profile + +import ( + "fmt" + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/profile" + "github.com/fastly/cli/pkg/text" +) + +// DeleteCommand represents a Kingpin command. +type DeleteCommand struct { + argparser.Base + + profile string +} + +// NewDeleteCommand returns a usable command registered under the parent. +func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { + var c DeleteCommand + c.Globals = g + c.CmdClause = parent.Command("delete", "Delete user profile") + c.CmdClause.Arg("profile", "Profile to delete").Short('x').Required().StringVar(&c.profile) + return &c +} + +// Exec invokes the application logic for the command. +func (c *DeleteCommand) Exec(_ io.Reader, out io.Writer) error { + if ok := profile.Delete(c.profile, c.Globals.Config.Profiles); ok { + if err := c.Globals.Config.Write(c.Globals.ConfigPath); err != nil { + return err + } + if c.Globals.Verbose() { + text.Break(out) + } + text.Success(out, "Profile '%s' deleted", c.profile) + + if _, p := profile.Default(c.Globals.Config.Profiles); p == nil && len(c.Globals.Config.Profiles) > 0 { + text.Break(out) + text.Warning(out, profile.NoDefaults) + } + return nil + } + return fmt.Errorf("the specified profile does not exist") +} diff --git a/pkg/commands/profile/doc.go b/pkg/commands/profile/doc.go new file mode 100644 index 000000000..208a8c919 --- /dev/null +++ b/pkg/commands/profile/doc.go @@ -0,0 +1,2 @@ +// Package profile contains commands to manage user profiles. +package profile diff --git a/pkg/commands/profile/list.go b/pkg/commands/profile/list.go new file mode 100644 index 000000000..fc8e2b430 --- /dev/null +++ b/pkg/commands/profile/list.go @@ -0,0 +1,86 @@ +package profile + +import ( + "errors" + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/auth" + "github.com/fastly/cli/pkg/config" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/profile" + "github.com/fastly/cli/pkg/text" +) + +// ListCommand represents a Kingpin command. +type ListCommand struct { + argparser.Base + argparser.JSONOutput +} + +// NewListCommand returns a usable command registered under the parent. +func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { + var c ListCommand + c.Globals = g + c.CmdClause = parent.Command("list", "List user profiles") + c.RegisterFlagBool(c.JSONFlag()) // --json + return &c +} + +// Exec invokes the application logic for the command. +func (c *ListCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + if ok, err := c.WriteJSON(out, c.Globals.Config.Profiles); ok { + return err + } + + if c.Globals.Config.Profiles == nil { + msg := "no profiles available" + return fsterr.RemediationError{ + Inner: errors.New(msg), + Remediation: fsterr.ProfileRemediation, + } + } + + if len(c.Globals.Config.Profiles) == 0 { + text.Break(out) + text.Description(out, "No profiles defined. To create a profile, run", "fastly profile create ") + return nil + } + + name, p := profile.Default(c.Globals.Config.Profiles) + if p == nil { + text.Warning(out, profile.NoDefaults) + } else { + if c.Globals.Verbose() { + text.Break(out) + } + text.Info(out, "Default profile highlighted in red.\n\n") + display(name, p, out, text.BoldRed) + } + + for k, v := range c.Globals.Config.Profiles { + if !v.Default { + text.Break(out) + display(k, v, out, text.Bold) + } + } + return nil +} + +func display(k string, v *config.Profile, out io.Writer, style func(a ...any) string) { + text.Output(out, style(k)) + text.Break(out) + text.Output(out, "%s: %t", style("Default"), v.Default) + text.Output(out, "%s: %s", style("Email"), v.Email) + text.Output(out, "%s: %s", style("Token"), v.Token) + text.Output(out, "%s: %t", style("SSO"), !auth.IsLongLivedToken(v)) + if !auth.IsLongLivedToken(v) { + text.Output(out, "%s: %s", style("Customer ID"), v.CustomerID) + text.Output(out, "%s: %s", style("Customer Name"), v.CustomerName) + } +} diff --git a/pkg/commands/profile/profile_test.go b/pkg/commands/profile/profile_test.go new file mode 100644 index 000000000..ab0999c88 --- /dev/null +++ b/pkg/commands/profile/profile_test.go @@ -0,0 +1,739 @@ +package profile_test + +import ( + "fmt" + "path/filepath" + "testing" + "time" + + "github.com/fastly/go-fastly/v10/fastly" + + root "github.com/fastly/cli/pkg/commands/profile" + "github.com/fastly/cli/pkg/config" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" + fsttime "github.com/fastly/cli/pkg/time" +) + +func TestProfileCreate(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate profile creation works", + Args: "foo", + API: mock.API{ + GetTokenSelfFn: getToken, + GetUserFn: getUser, + }, + Stdin: []string{"some_token"}, + Env: &testutil.EnvConfig{ + Opts: &testutil.EnvOpts{ + Copy: []testutil.FileIO{ + { + Src: filepath.Join("testdata", "config.toml"), + Dst: "config.toml", + }, + }, + }, + EditScenario: func(scenario *testutil.CLIScenario, rootdir string) { + scenario.ConfigPath = filepath.Join(rootdir, "config.toml") + }, + }, + WantOutputs: []string{ + "Fastly API token:", + "Validating token", + "Persisting configuration", + "Profile 'foo' created", + }, + }, + { + Name: "validate profile duplication", + Args: "foo", + Env: &testutil.EnvConfig{ + Opts: &testutil.EnvOpts{ + Copy: []testutil.FileIO{ + { + Src: filepath.Join("testdata", "config.toml"), + Dst: "config.toml", + }, + }, + }, + EditScenario: func(scenario *testutil.CLIScenario, rootdir string) { + scenario.ConfigPath = filepath.Join(rootdir, "config.toml") + }, + }, + ConfigFile: &config.File{ + Profiles: config.Profiles{ + "foo": &config.Profile{ + Default: true, + Email: "foo@example.com", + Token: "123", + }, + }, + }, + WantError: "profile 'foo' already exists", + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, "create"}, scenarios) +} + +func TestProfileDelete(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate profile deletion works", + Args: "foo", + Env: &testutil.EnvConfig{ + Opts: &testutil.EnvOpts{ + Copy: []testutil.FileIO{ + { + Src: filepath.Join("testdata", "config.toml"), + Dst: "config.toml", + }, + }, + }, + EditScenario: func(scenario *testutil.CLIScenario, rootdir string) { + scenario.ConfigPath = filepath.Join(rootdir, "config.toml") + }, + }, + ConfigFile: &config.File{ + Profiles: config.Profiles{ + "foo": &config.Profile{ + Default: true, + Email: "foo@example.com", + Token: "123", + }, + }, + }, + WantOutput: "Profile 'foo' deleted", + }, + { + Name: "validate incorrect profile", + Args: "unknown", + Env: &testutil.EnvConfig{ + Opts: &testutil.EnvOpts{ + Copy: []testutil.FileIO{ + { + Src: filepath.Join("testdata", "config.toml"), + Dst: "config.toml", + }, + }, + }, + EditScenario: func(scenario *testutil.CLIScenario, rootdir string) { + scenario.ConfigPath = filepath.Join(rootdir, "config.toml") + }, + }, + WantError: "the specified profile does not exist", + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, "delete"}, scenarios) +} + +func TestProfileList(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate listing profiles works", + Env: &testutil.EnvConfig{ + Opts: &testutil.EnvOpts{ + Copy: []testutil.FileIO{ + { + Src: filepath.Join("testdata", "config.toml"), + Dst: "config.toml", + }, + }, + }, + EditScenario: func(scenario *testutil.CLIScenario, rootdir string) { + scenario.ConfigPath = filepath.Join(rootdir, "config.toml") + }, + }, + ConfigFile: &config.File{ + Profiles: config.Profiles{ + "foo": &config.Profile{ + Default: true, + Email: "foo@example.com", + Token: "123", + }, + "bar": &config.Profile{ + Default: false, + Email: "bar@example.com", + Token: "456", + }, + }, + }, + WantOutputs: []string{ + "Default profile highlighted in red.", + "foo\n\nDefault: true\nEmail: foo@example.com\nToken: 123", + "bar\n\nDefault: false\nEmail: bar@example.com\nToken: 456", + }, + }, + { + Name: "validate no profiles defined", + Env: &testutil.EnvConfig{ + Opts: &testutil.EnvOpts{ + Copy: []testutil.FileIO{ + { + Src: filepath.Join("testdata", "config.toml"), + Dst: "config.toml", + }, + }, + }, + EditScenario: func(scenario *testutil.CLIScenario, rootdir string) { + scenario.ConfigPath = filepath.Join(rootdir, "config.toml") + }, + }, + ConfigFile: &config.File{}, + WantError: "no profiles available", + }, + // NOTE: The following test is subtly different to the previous one in that + // our logic checks whether the config.Profiles map type is nil. If it is + // then we error (see above test), otherwise if the map is set but there + // are no profiles, then we notify the user no profiles exist. + { + Name: "validate no profiles available", + Env: &testutil.EnvConfig{ + Opts: &testutil.EnvOpts{ + Copy: []testutil.FileIO{ + { + Src: filepath.Join("testdata", "config.toml"), + Dst: "config.toml", + }, + }, + }, + EditScenario: func(scenario *testutil.CLIScenario, rootdir string) { + scenario.ConfigPath = filepath.Join(rootdir, "config.toml") + }, + }, + ConfigFile: &config.File{ + Profiles: config.Profiles{}, + }, + WantOutputs: []string{ + "No profiles defined. To create a profile, run", + "fastly profile create ", + }, + }, + { + Name: "validate listing profiles displays warning if no default set", + Env: &testutil.EnvConfig{ + Opts: &testutil.EnvOpts{ + Copy: []testutil.FileIO{ + { + Src: filepath.Join("testdata", "config.toml"), + Dst: "config.toml", + }, + }, + }, + EditScenario: func(scenario *testutil.CLIScenario, rootdir string) { + scenario.ConfigPath = filepath.Join(rootdir, "config.toml") + }, + }, + ConfigFile: &config.File{ + Profiles: config.Profiles{ + "foo": &config.Profile{ + Default: false, + Email: "foo@example.com", + Token: "123", + }, + "bar": &config.Profile{ + Default: false, + Email: "bar@example.com", + Token: "456", + }, + }, + }, + WantOutputs: []string{ + "At least one account profile should be set as the 'default'.", + "foo\n\nDefault: false\nEmail: foo@example.com\nToken: 123", + "bar\n\nDefault: false\nEmail: bar@example.com\nToken: 456", + }, + }, + { + Name: "validate listing profiles with --verbose and --json causes an error", + Args: "--verbose --json", + Env: &testutil.EnvConfig{ + Opts: &testutil.EnvOpts{ + Copy: []testutil.FileIO{ + { + Src: filepath.Join("testdata", "config.toml"), + Dst: "config.toml", + }, + }, + }, + EditScenario: func(scenario *testutil.CLIScenario, rootdir string) { + scenario.ConfigPath = filepath.Join(rootdir, "config.toml") + }, + }, + ConfigFile: &config.File{ + Profiles: config.Profiles{ + "foo": &config.Profile{ + Default: false, + Email: "foo@example.com", + Token: "123", + }, + "bar": &config.Profile{ + Default: false, + Email: "bar@example.com", + Token: "456", + }, + }, + }, + WantError: "invalid flag combination, --verbose and --json", + }, + { + Name: "validate listing profiles with --json displays data correctly", + Args: "--json", + Env: &testutil.EnvConfig{ + Opts: &testutil.EnvOpts{ + Copy: []testutil.FileIO{ + { + Src: filepath.Join("testdata", "config.toml"), + Dst: "config.toml", + }, + }, + }, + EditScenario: func(scenario *testutil.CLIScenario, rootdir string) { + scenario.ConfigPath = filepath.Join(rootdir, "config.toml") + }, + }, + ConfigFile: &config.File{ + Profiles: config.Profiles{ + "foo": &config.Profile{ + Default: false, + Email: "foo@example.com", + Token: "123", + }, + "bar": &config.Profile{ + Default: false, + Email: "bar@example.com", + Token: "456", + }, + }, + }, + WantOutput: `{ + "bar": { + "access_token": "", + "access_token_created": 0, + "access_token_ttl": 0, + "customer_id": "", + "customer_name": "", + "default": false, + "email": "bar@example.com", + "refresh_token": "", + "refresh_token_created": 0, + "refresh_token_ttl": 0, + "token": "456" + }, + "foo": { + "access_token": "", + "access_token_created": 0, + "access_token_ttl": 0, + "customer_id": "", + "customer_name": "", + "default": false, + "email": "foo@example.com", + "refresh_token": "", + "refresh_token_created": 0, + "refresh_token_ttl": 0, + "token": "123" + } +}`, + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, "list"}, scenarios) +} + +func TestProfileSwitch(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate switching to unknown profile returns an error", + Args: "unknown", + Env: &testutil.EnvConfig{ + Opts: &testutil.EnvOpts{ + Copy: []testutil.FileIO{ + { + Src: filepath.Join("testdata", "config.toml"), + Dst: "config.toml", + }, + }, + }, + EditScenario: func(scenario *testutil.CLIScenario, rootdir string) { + scenario.ConfigPath = filepath.Join(rootdir, "config.toml") + }, + }, + WantError: "the profile 'unknown' does not exist", + }, + { + Name: "validate switching profiles works", + Args: "bar", + Env: &testutil.EnvConfig{ + Opts: &testutil.EnvOpts{ + Copy: []testutil.FileIO{ + { + Src: filepath.Join("testdata", "config.toml"), + Dst: "config.toml", + }, + }, + }, + EditScenario: func(scenario *testutil.CLIScenario, rootdir string) { + scenario.ConfigPath = filepath.Join(rootdir, "config.toml") + }, + }, + ConfigFile: &config.File{ + Profiles: config.Profiles{ + "foo": &config.Profile{ + Default: true, + Email: "foo@example.com", + Token: "123", + }, + "bar": &config.Profile{ + Default: false, + Email: "bar@example.com", + Token: "456", + }, + }, + }, + WantOutput: "Profile switched to 'bar'", + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, "switch"}, scenarios) +} + +func TestProfileToken(t *testing.T) { + now := time.Now() + + scenarios := []testutil.CLIScenario{ + { + Name: "validate the active profile non-OIDC token is displayed by default", + Env: &testutil.EnvConfig{ + Opts: &testutil.EnvOpts{ + Copy: []testutil.FileIO{ + { + Src: filepath.Join("testdata", "config.toml"), + Dst: "config.toml", + }, + }, + }, + EditScenario: func(scenario *testutil.CLIScenario, rootdir string) { + scenario.ConfigPath = filepath.Join(rootdir, "config.toml") + }, + }, + ConfigFile: &config.File{ + Profiles: config.Profiles{ + "foo": &config.Profile{ + Default: true, + Email: "foo@example.com", + Token: "123", + }, + "bar": &config.Profile{ + Default: false, + Email: "bar@example.com", + Token: "456", + }, + }, + }, + WantOutput: "123", + }, + { + Name: "validate non-OIDC token is displayed for the specified profile", + Args: "bar", // we choose a non-default profile + Env: &testutil.EnvConfig{ + Opts: &testutil.EnvOpts{ + Copy: []testutil.FileIO{ + { + Src: filepath.Join("testdata", "config.toml"), + Dst: "config.toml", + }, + }, + }, + EditScenario: func(scenario *testutil.CLIScenario, rootdir string) { + scenario.ConfigPath = filepath.Join(rootdir, "config.toml") + }, + }, + ConfigFile: &config.File{ + Profiles: config.Profiles{ + "foo": &config.Profile{ + Default: true, + Email: "foo@example.com", + Token: "123", + }, + "bar": &config.Profile{ + Default: false, + Email: "bar@example.com", + Token: "456", + }, + }, + }, + WantOutput: "456", + }, + { + Name: "validate non-OIDC token is displayed for the specified profile using global --profile", + Args: "--profile bar", // we choose a non-default profile + Env: &testutil.EnvConfig{ + Opts: &testutil.EnvOpts{ + Copy: []testutil.FileIO{ + { + Src: filepath.Join("testdata", "config.toml"), + Dst: "config.toml", + }, + }, + }, + EditScenario: func(scenario *testutil.CLIScenario, rootdir string) { + scenario.ConfigPath = filepath.Join(rootdir, "config.toml") + }, + }, + ConfigFile: &config.File{ + Profiles: config.Profiles{ + "foo": &config.Profile{ + Default: true, + Email: "foo@example.com", + Token: "123", + }, + "bar": &config.Profile{ + Default: false, + Email: "bar@example.com", + Token: "456", + }, + }, + }, + WantOutput: "456", + }, + { + Name: "validate an unrecognised profile causes an error", + Args: "unknown", + Env: &testutil.EnvConfig{ + Opts: &testutil.EnvOpts{ + Copy: []testutil.FileIO{ + { + Src: filepath.Join("testdata", "config.toml"), + Dst: "config.toml", + }, + }, + }, + EditScenario: func(scenario *testutil.CLIScenario, rootdir string) { + scenario.ConfigPath = filepath.Join(rootdir, "config.toml") + }, + }, + WantError: "profile 'unknown' does not exist", + }, + { + Name: "validate that an expired OIDC token generates an error", + Env: &testutil.EnvConfig{ + Opts: &testutil.EnvOpts{ + Copy: []testutil.FileIO{ + { + Src: filepath.Join("testdata", "config.toml"), + Dst: "config.toml", + }, + }, + }, + EditScenario: func(scenario *testutil.CLIScenario, rootdir string) { + scenario.ConfigPath = filepath.Join(rootdir, "config.toml") + }, + }, + ConfigFile: &config.File{ + Profiles: config.Profiles{ + "foo": &config.Profile{ + Default: true, + Email: "foo@example.com", + Token: "123", + RefreshTokenCreated: now.Add(time.Duration(-1200) * time.Second).Unix(), + RefreshTokenTTL: 600, + }, + }, + }, + WantError: fmt.Sprintf("the token in profile 'foo' expired at '%s'", now.Add(time.Duration(-600)*time.Second).UTC().Format(fsttime.Format)), + }, + { + Name: "validate that a soon-to-expire OIDC token generates an error", + Env: &testutil.EnvConfig{ + Opts: &testutil.EnvOpts{ + Copy: []testutil.FileIO{ + { + Src: filepath.Join("testdata", "config.toml"), + Dst: "config.toml", + }, + }, + }, + EditScenario: func(scenario *testutil.CLIScenario, rootdir string) { + scenario.ConfigPath = filepath.Join(rootdir, "config.toml") + }, + }, + ConfigFile: &config.File{ + Profiles: config.Profiles{ + "foo": &config.Profile{ + Default: true, + Email: "foo@example.com", + Token: "123", + RefreshTokenCreated: now.Unix(), + RefreshTokenTTL: 30, + }, + }, + }, + WantError: fmt.Sprintf("the token in profile 'foo' will expire at '%s'", now.Add(time.Duration(30)*time.Second).UTC().Format(fsttime.Format)), + }, + { + Name: "validate that a soon-to-expire OIDC token with a non-default TTL does not generate an error", + Args: "--ttl 30s", + Env: &testutil.EnvConfig{ + Opts: &testutil.EnvOpts{ + Copy: []testutil.FileIO{ + { + Src: filepath.Join("testdata", "config.toml"), + Dst: "config.toml", + }, + }, + }, + EditScenario: func(scenario *testutil.CLIScenario, rootdir string) { + scenario.ConfigPath = filepath.Join(rootdir, "config.toml") + }, + }, + ConfigFile: &config.File{ + Profiles: config.Profiles{ + "foo": &config.Profile{ + Default: true, + Email: "foo@example.com", + Token: "123", + RefreshTokenCreated: now.Unix(), + RefreshTokenTTL: 60, + }, + }, + }, + WantOutput: "123", + }, + { + Name: "validate that an OIDC token with a long non-default TTL generates an error", + Args: "--ttl 1800s", + Env: &testutil.EnvConfig{ + Opts: &testutil.EnvOpts{ + Copy: []testutil.FileIO{ + { + Src: filepath.Join("testdata", "config.toml"), + Dst: "config.toml", + }, + }, + }, + EditScenario: func(scenario *testutil.CLIScenario, rootdir string) { + scenario.ConfigPath = filepath.Join(rootdir, "config.toml") + }, + }, + ConfigFile: &config.File{ + Profiles: config.Profiles{ + "foo": &config.Profile{ + Default: true, + Email: "foo@example.com", + Token: "123", + RefreshTokenCreated: now.Unix(), + RefreshTokenTTL: 1200, + }, + }, + }, + WantError: fmt.Sprintf("the token in profile 'foo' will expire at '%s'", now.Add(time.Duration(1200)*time.Second).UTC().Format(fsttime.Format)), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, "token"}, scenarios) +} + +func TestProfileUpdate(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate updating unknown profile returns an error", + Args: "unknown", + Env: &testutil.EnvConfig{ + Opts: &testutil.EnvOpts{ + Copy: []testutil.FileIO{ + { + Src: filepath.Join("testdata", "config.toml"), + Dst: "config.toml", + }, + }, + }, + EditScenario: func(scenario *testutil.CLIScenario, rootdir string) { + scenario.ConfigPath = filepath.Join(rootdir, "config.toml") + }, + }, + WantError: "the profile 'unknown' does not exist", + }, + { + Name: "validate updating profile works", + Args: "bar", // we choose a non-default profile + API: mock.API{ + GetTokenSelfFn: getToken, + GetUserFn: getUser, + }, + Env: &testutil.EnvConfig{ + Opts: &testutil.EnvOpts{ + Copy: []testutil.FileIO{ + { + Src: filepath.Join("testdata", "config.toml"), + Dst: "config.toml", + }, + }, + }, + EditScenario: func(scenario *testutil.CLIScenario, rootdir string) { + scenario.ConfigPath = filepath.Join(rootdir, "config.toml") + }, + }, + ConfigFile: &config.File{ + Profiles: config.Profiles{ + "foo": &config.Profile{ + Default: true, + Email: "foo@example.com", + Token: "123", + }, + "bar": &config.Profile{ + Default: false, + Email: "bar@example.com", + Token: "456", + }, + }, + }, + Stdin: []string{ + "", // we skip SSO prompt + "", // we skip updating the token + "y", // we set the profile to be the default + }, + WantOutput: "Profile 'bar' updated", + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, "update"}, scenarios) +} + +func getToken() (*fastly.Token, error) { + t := testutil.Date + + return &fastly.Token{ + TokenID: fastly.ToPointer("123"), + Name: fastly.ToPointer("Foo"), + UserID: fastly.ToPointer("456"), + Services: []string{"a", "b"}, + Scope: fastly.ToPointer(fastly.TokenScope(fmt.Sprintf("%s %s", fastly.PurgeAllScope, fastly.GlobalReadScope))), + IP: fastly.ToPointer("127.0.0.1"), + CreatedAt: &t, + ExpiresAt: &t, + LastUsedAt: &t, + }, nil +} + +func getUser(i *fastly.GetUserInput) (*fastly.User, error) { + t := testutil.Date + + return &fastly.User{ + UserID: fastly.ToPointer(i.UserID), + Login: fastly.ToPointer("foo@example.com"), + Name: fastly.ToPointer("foo"), + Role: fastly.ToPointer("user"), + CustomerID: fastly.ToPointer("abc"), + EmailHash: fastly.ToPointer("example-hash"), + LimitServices: fastly.ToPointer(true), + Locked: fastly.ToPointer(true), + RequireNewPassword: fastly.ToPointer(true), + TwoFactorAuthEnabled: fastly.ToPointer(true), + TwoFactorSetupRequired: fastly.ToPointer(true), + CreatedAt: &t, + DeletedAt: &t, + UpdatedAt: &t, + }, nil +} diff --git a/pkg/commands/profile/root.go b/pkg/commands/profile/root.go new file mode 100644 index 000000000..da20b4c90 --- /dev/null +++ b/pkg/commands/profile/root.go @@ -0,0 +1,31 @@ +package profile + +import ( + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" +) + +// RootCommand is the parent command for all subcommands in this package. +// It should be installed under the primary root command. +type RootCommand struct { + argparser.Base + // no flags +} + +// CommandName is the string to be used to invoke this command. +const CommandName = "profile" + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent argparser.Registerer, g *global.Data) *RootCommand { + var c RootCommand + c.Globals = g + c.CmdClause = parent.Command(CommandName, "Manage user profiles") + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { + panic("unreachable") +} diff --git a/pkg/commands/profile/switch.go b/pkg/commands/profile/switch.go new file mode 100644 index 000000000..ce7cb37d6 --- /dev/null +++ b/pkg/commands/profile/switch.go @@ -0,0 +1,88 @@ +package profile + +import ( + "fmt" + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/sso" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/profile" + "github.com/fastly/cli/pkg/text" +) + +// SwitchCommand represents a Kingpin command. +type SwitchCommand struct { + argparser.Base + + profile string + ssoCmd *sso.RootCommand +} + +// NewSwitchCommand returns a usable command registered under the parent. +func NewSwitchCommand(parent argparser.Registerer, g *global.Data, ssoCmd *sso.RootCommand) *SwitchCommand { + var c SwitchCommand + c.Globals = g + c.ssoCmd = ssoCmd + c.CmdClause = parent.Command("switch", "Switch user profile") + c.CmdClause.Arg("profile", "Profile to switch to").Short('p').Required().StringVar(&c.profile) + return &c +} + +// Exec invokes the application logic for the command. +func (c *SwitchCommand) Exec(in io.Reader, out io.Writer) error { + // We get the named profile to check if it's an SSO-based profile. + // If we're switching to an SSO-based profile, then we need to re-auth. + p := profile.Get(c.profile, c.Globals.Config.Profiles) + if p == nil { + err := fmt.Errorf(profile.DoesNotExist, c.profile) + c.Globals.ErrLog.Add(err) + return fsterr.RemediationError{ + Inner: err, + Remediation: fsterr.ProfileRemediation, + } + } + if isSSOToken(p) { + // IMPORTANT: We need to set profile fields for `sso` command. + // + // This is so the `sso` command will use this information to trigger the + // correct authentication flow. + c.ssoCmd.InvokedFromProfileSwitch = true + c.ssoCmd.ProfileSwitchName = c.profile + c.ssoCmd.ProfileSwitchEmail = p.Email + c.ssoCmd.ProfileSwitchCustomerID = p.CustomerID + c.ssoCmd.ProfileDefault = true + + err := c.ssoCmd.Exec(in, out) + if err != nil { + return fmt.Errorf("failed to authenticate: %w", err) + } + text.Success(out, "\nProfile switched to '%s'", c.profile) + return nil + } + + // We call SetDefault for its side effect of resetting all other profiles to have + // their Default field set to false. + ps, ok := profile.SetDefault(c.profile, c.Globals.Config.Profiles) + if !ok { + err := fmt.Errorf(profile.DoesNotExist, c.profile) + c.Globals.ErrLog.Add(err) + return fsterr.RemediationError{ + Inner: err, + Remediation: fsterr.ProfileRemediation, + } + } + c.Globals.Config.Profiles = ps + + if err := c.Globals.Config.Write(c.Globals.ConfigPath); err != nil { + c.Globals.ErrLog.Add(err) + return fmt.Errorf("error saving config file: %w", err) + } + + if c.Globals.Verbose() { + text.Break(out) + } + text.Success(out, "Profile switched to '%s'", c.profile) + return nil +} diff --git a/pkg/commands/profile/testdata/config.toml b/pkg/commands/profile/testdata/config.toml new file mode 100644 index 000000000..b907b1b93 --- /dev/null +++ b/pkg/commands/profile/testdata/config.toml @@ -0,0 +1,4 @@ +config_version = 2 + +[fastly] + api_endpoint = "https://api.fastly.com" diff --git a/pkg/commands/profile/token.go b/pkg/commands/profile/token.go new file mode 100644 index 000000000..0c2c69c48 --- /dev/null +++ b/pkg/commands/profile/token.go @@ -0,0 +1,104 @@ +package profile + +import ( + "errors" + "fmt" + "io" + "time" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/config" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/profile" + "github.com/fastly/cli/pkg/text" + fsttime "github.com/fastly/cli/pkg/time" +) + +// TokenCommand represents a Kingpin command. +type TokenCommand struct { + argparser.Base + profile string + tokenTTL time.Duration +} + +// NewTokenCommand returns a new command registered in the parent. +func NewTokenCommand(parent argparser.Registerer, g *global.Data) *TokenCommand { + var c TokenCommand + c.Globals = g + c.CmdClause = parent.Command("token", "Print API token (defaults to the 'active' profile)") + c.CmdClause.Arg("profile", "Print API token for the named profile").Short('p').StringVar(&c.profile) + c.CmdClause.Flag("ttl", "Amount of time for which the token must be valid (in seconds 's', minutes 'm', or hours 'h')").Default(defaultTokenTTL.String()).DurationVar(&c.tokenTTL) + return &c +} + +// By default tokens must be valid for at least 5 minutes to be +// considered valid. +const defaultTokenTTL time.Duration = 5 * time.Minute + +// Exec implements the command interface. +func (c *TokenCommand) Exec(_ io.Reader, out io.Writer) (err error) { + var name string + if c.profile != "" { + name = c.profile + } + if c.Globals.Flags.Profile != "" { + name = c.Globals.Flags.Profile + // NOTE: If global --profile is set, it take precedence over 'profile' arg. + // It's unlikely someone will provide both, but we'll code defensively. + } + + if name != "" { + if p := profile.Get(name, c.Globals.Config.Profiles); p != nil { + if err = checkTokenValidity(name, p, c.tokenTTL); err != nil { + return err + } + text.Output(out, p.Token) + return nil + } + msg := fmt.Sprintf(profile.DoesNotExist, name) + return fsterr.RemediationError{ + Inner: errors.New(msg), + Remediation: fsterr.ProfileRemediation, + } + } + + // If no 'profile' arg or global --profile, then we'll use 'active' profile. + if name, p := profile.Default(c.Globals.Config.Profiles); p != nil { + if err = checkTokenValidity(name, p, c.tokenTTL); err != nil { + return err + } + text.Output(out, p.Token) + return nil + } + return fsterr.RemediationError{ + Inner: errors.New("no profiles available"), + Remediation: fsterr.ProfileRemediation, + } +} + +func checkTokenValidity(profileName string, p *config.Profile, ttl time.Duration) (err error) { + // if the token in the profile was not obtained via OIDC, + // there is no expiration information available + if p.RefreshTokenCreated == 0 { + return nil + } + + var msg string + expiry := time.Unix(p.RefreshTokenCreated, 0).Add(time.Duration(p.RefreshTokenTTL) * time.Second) + + if expiry.After(time.Now().Add(ttl)) { + return nil + } + + if expiry.Before(time.Now()) { + msg = fmt.Sprintf(profile.TokenExpired, profileName, expiry.UTC().Format(fsttime.Format)) + } else { + msg = fmt.Sprintf(profile.TokenWillExpire, profileName, expiry.UTC().Format(fsttime.Format)) + } + + return fsterr.RemediationError{ + Inner: errors.New(msg), + Remediation: fsterr.TokenExpirationRemediation, + } +} diff --git a/pkg/commands/profile/update.go b/pkg/commands/profile/update.go new file mode 100644 index 000000000..acc9278da --- /dev/null +++ b/pkg/commands/profile/update.go @@ -0,0 +1,279 @@ +package profile + +import ( + "errors" + "fmt" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/api" + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/commands/sso" + "github.com/fastly/cli/pkg/config" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/profile" + "github.com/fastly/cli/pkg/text" +) + +// UpdateCommand represents a Kingpin command. +type UpdateCommand struct { + argparser.Base + ssoCmd *sso.RootCommand + + automationToken bool + profile string + sso bool +} + +// NewUpdateCommand returns a usable command registered under the parent. +func NewUpdateCommand(parent argparser.Registerer, g *global.Data, ssoCmd *sso.RootCommand) *UpdateCommand { + var c UpdateCommand + c.Globals = g + c.ssoCmd = ssoCmd + c.CmdClause = parent.Command("update", "Update user profile") + c.CmdClause.Arg("profile", "Profile to update (defaults to the currently active profile)").Short('p').StringVar(&c.profile) + c.CmdClause.Flag("automation-token", "Expected input will be an 'automation token' instead of a 'user token'").BoolVar(&c.automationToken) + c.CmdClause.Flag("sso", "Update profile to use an SSO-based token").BoolVar(&c.sso) + return &c +} + +// Exec invokes the application logic for the command. +func (c *UpdateCommand) Exec(in io.Reader, out io.Writer) error { + profileName, p, err := c.identifyProfile() + if err != nil { + return fmt.Errorf("failed to identify the profile to update: %w", err) + } + if c.Globals.Verbose() { + text.Break(out) + } + text.Info(out, "Profile being updated: '%s'.\n\n", profileName) + + err = c.updateToken(profileName, p, in, out) + if err != nil { + return fmt.Errorf("failed to update token: %w", err) + } + + // Set to true for --auto-yes/--non-interactive flags, otherwise prompt user. + makeDefault := true + + if !c.Globals.Flags.AutoYes && !c.Globals.Flags.NonInteractive { + text.Break(out) + makeDefault, err = text.AskYesNo(out, text.BoldYellow("Make profile the default? [y/N] "), in) + text.Break(out) + if err != nil { + return err + } + } + + if makeDefault { + err := c.setAsDefault(profileName) + if err != nil { + return fmt.Errorf("failed to update token: %w", err) + } + } + + text.Success(out, "\nProfile '%s' updated", profileName) + return nil +} + +func (c *UpdateCommand) identifyProfile() (string, *config.Profile, error) { + var ( + profileName string + p *config.Profile + ) + + // If profile argument not set and no --profile flag set, then identify the + // default profile to update. + if c.profile == "" && c.Globals.Flags.Profile == "" { + profileName, p = profile.Default(c.Globals.Config.Profiles) + if p == nil { + return "", nil, fsterr.RemediationError{ + Inner: fmt.Errorf("no active profile"), + Remediation: profile.NoDefaults, + } + } + } else { + // Otherwise, acquire the profile the user has specified. + profileName = c.profile + if c.Globals.Flags.Profile != "" { + profileName = c.Globals.Flags.Profile + } + p = profile.Get(profileName, c.Globals.Config.Profiles) + if p == nil { + msg := fmt.Sprintf(profile.DoesNotExist, c.profile) + return "", nil, fsterr.RemediationError{ + Inner: errors.New(msg), + Remediation: fsterr.ProfileRemediation, + } + } + } + + return profileName, p, nil +} + +func (c *UpdateCommand) updateToken(profileName string, p *config.Profile, in io.Reader, out io.Writer) error { + // FIXME: Put back messaging once SSO is GA. + // if !c.sso && !isSSOToken(p) { + // text.Info(out, "When updating a profile you can either paste in a long-lived token or allow the Fastly CLI to generate a short-lived token that can be automatically refreshed. To update this profile to use an SSO-based token, pass the `--sso` flag: `fastly profile update --sso`.\n\n") + // } + + if c.sso || isSSOToken(p) { + // IMPORTANT: We need to set profile fields for `sso` command. + // + // This is so the `sso` command will use this information to update + // the specific profile. + c.ssoCmd.InvokedFromProfileUpdate = true + c.ssoCmd.ProfileUpdateName = profileName + c.ssoCmd.ProfileDefault = false // set to false, as later we prompt for this + + // NOTE: The `sso` command already handles writing config back to disk. + // So unlike `c.staticTokenFlow` (below) we don't have to do that here. + err := c.ssoCmd.Exec(in, out) + if err != nil { + return fmt.Errorf("failed to authenticate: %w", err) + } + text.Break(out) + } else { + if err := c.staticTokenFlow(profileName, p, in, out); err != nil { + return fmt.Errorf("failed to process the static token flow: %w", err) + } + // Write the in-memory representation back to disk. + if err := c.Globals.Config.Write(c.Globals.ConfigPath); err != nil { + c.Globals.ErrLog.Add(err) + return fmt.Errorf("error saving config file: %w", err) + } + } + + return nil +} + +func (c *UpdateCommand) setAsDefault(profileName string) error { + p, ok := profile.SetDefault(profileName, c.Globals.Config.Profiles) + if !ok { + return errors.New("failed to update the profile's default field") + } + c.Globals.Config.Profiles = p + + // Write the in-memory representation back to disk. + if err := c.Globals.Config.Write(c.Globals.ConfigPath); err != nil { + c.Globals.ErrLog.Add(err) + return fmt.Errorf("error saving config file: %w", err) + } + return nil +} + +// validateToken ensures the token can be used to acquire user data. +func (c *UpdateCommand) validateToken(token, endpoint string, spinner text.Spinner) (string, error) { + var ( + client api.Interface + err error + t *fastly.Token + ) + err = spinner.Process("Validating token", func(_ *text.SpinnerWrapper) error { + client, err = c.Globals.APIClientFactory(token, endpoint, c.Globals.Flags.Debug) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Endpoint": endpoint, + }) + return fmt.Errorf("error regenerating Fastly API client: %w", err) + } + + t, err = client.GetTokenSelf() + if err != nil { + c.Globals.ErrLog.Add(err) + return fmt.Errorf("error validating token: %w", err) + } + return nil + }) + if err != nil { + return "", err + } + if c.automationToken { + return fmt.Sprintf("Automation Token (%s)", fastly.ToValue(t.TokenID)), nil + } + + var user *fastly.User + err = spinner.Process("Getting user data", func(_ *text.SpinnerWrapper) error { + user, err = client.GetUser(&fastly.GetUserInput{ + UserID: fastly.ToValue(t.UserID), + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "User ID": t.UserID, + }) + return fsterr.RemediationError{ + Inner: fmt.Errorf("error fetching token user: %w", err), + Remediation: "If providing an 'automation token', retry the command with the `--automation-token` flag set.", + } + } + return nil + }) + if err != nil { + return "", err + } + return fastly.ToValue(user.Login), nil +} + +func (c *UpdateCommand) staticTokenFlow(profileName string, p *config.Profile, in io.Reader, out io.Writer) error { + opts := []profile.EditOption{} + + token, err := text.InputSecure(out, text.BoldYellow("Profile token: (leave blank to skip): "), in) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + // User didn't want to change their token value so reassign original. + if token == "" { + token = p.Token + } else { + opts = append(opts, func(p *config.Profile) { + p.Token = token + }) + } + text.Break(out) + + opts = append(opts, func(p *config.Profile) { + p.Default = false // set to false, as later we prompt for this + }) + + text.Break(out) + + spinner, err := text.NewSpinner(out) + if err != nil { + return err + } + defer func() { + if err != nil { + c.Globals.ErrLog.Add(err) + } + }() + + endpoint, _ := c.Globals.APIEndpoint() + + email, err := c.validateToken(token, endpoint, spinner) + if err != nil { + return err + } + opts = append(opts, func(p *config.Profile) { + p.Email = email + }) + + ps, ok := profile.Edit(profileName, c.Globals.Config.Profiles, opts...) + if !ok { + msg := fmt.Sprintf(profile.DoesNotExist, profileName) + return fsterr.RemediationError{ + Inner: errors.New(msg), + Remediation: fsterr.ProfileRemediation, + } + } + c.Globals.Config.Profiles = ps + + return nil +} + +func isSSOToken(p *config.Profile) bool { + return p.AccessToken != "" && p.RefreshToken != "" && p.AccessTokenCreated > 0 && p.RefreshTokenCreated > 0 +} diff --git a/pkg/commands/purge/doc.go b/pkg/commands/purge/doc.go new file mode 100644 index 000000000..2add2229c --- /dev/null +++ b/pkg/commands/purge/doc.go @@ -0,0 +1,2 @@ +// Package purge contains commands to inspect and manipulate Fastly edge cache. +package purge diff --git a/pkg/commands/purge/purge_test.go b/pkg/commands/purge/purge_test.go new file mode 100644 index 000000000..c7792a428 --- /dev/null +++ b/pkg/commands/purge/purge_test.go @@ -0,0 +1,193 @@ +package purge_test + +import ( + "reflect" + "testing" + + "github.com/fastly/go-fastly/v10/fastly" + + root "github.com/fastly/cli/pkg/commands/purge" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" +) + +func TestPurgeAll(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --service-id flag", + Args: "--all", + WantError: "error reading service: no service ID found", + }, + { + Name: "validate --soft flag isn't usable", + Args: "--all --service-id 123 --soft", + WantError: "purge-all requests cannot be done in soft mode (--soft) and will always immediately invalidate all cached content associated with the service", + }, + { + Name: "validate PurgeAll API error", + API: mock.API{ + PurgeAllFn: func(_ *fastly.PurgeAllInput) (*fastly.Purge, error) { + return nil, testutil.Err + }, + }, + Args: "--all --service-id 123", + WantError: testutil.Err.Error(), + }, + { + Name: "validate PurgeAll API success", + API: mock.API{ + PurgeAllFn: func(_ *fastly.PurgeAllInput) (*fastly.Purge, error) { + return &fastly.Purge{ + Status: fastly.ToPointer("ok"), + }, nil + }, + }, + Args: "--all --service-id 123", + WantOutput: "Purge all status: ok", + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName}, scenarios) +} + +func TestPurgeKeys(t *testing.T) { + var keys []string + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --service-id flag", + Args: "--file ./testdata/keys", + WantError: "error reading service: no service ID found", + }, + { + Name: "validate PurgeKeys API error", + API: mock.API{ + PurgeKeysFn: func(_ *fastly.PurgeKeysInput) (map[string]string, error) { + return nil, testutil.Err + }, + }, + Args: "--file ./testdata/keys --service-id 123", + WantError: testutil.Err.Error(), + }, + { + Name: "validate PurgeKeys API success", + API: mock.API{ + PurgeKeysFn: func(i *fastly.PurgeKeysInput) (map[string]string, error) { + // Track the keys parsed + keys = i.Keys + + return map[string]string{ + "foo": "123", + "bar": "456", + "baz": "789", + }, nil + }, + }, + Args: "--file ./testdata/keys --service-id 123", + WantOutput: "KEY ID\nbar 456\nbaz 789\nfoo 123\n", + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName}, scenarios) + assertKeys(keys, t) +} + +// assertKeys validates that the --file flag is parsed correctly. It does this +// by ensuring the internal logic has parsed the given file and generated the +// correct []string type. +func assertKeys(keys []string, t *testing.T) { + want := []string{"foo", "bar", "baz"} + if !reflect.DeepEqual(keys, want) { + t.Errorf("wanted %s, have %s", want, keys) + } +} + +func TestPurgeKey(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --service-id flag", + Args: "--key foobar", + WantError: "error reading service: no service ID found", + }, + { + Name: "validate PurgeKey API error", + API: mock.API{ + PurgeKeyFn: func(_ *fastly.PurgeKeyInput) (*fastly.Purge, error) { + return nil, testutil.Err + }, + }, + Args: "--key foobar --service-id 123", + WantError: testutil.Err.Error(), + }, + { + Name: "validate PurgeKey API success", + API: mock.API{ + PurgeKeyFn: func(_ *fastly.PurgeKeyInput) (*fastly.Purge, error) { + return &fastly.Purge{ + Status: fastly.ToPointer("ok"), + PurgeID: fastly.ToPointer("123"), + }, nil + }, + }, + Args: "--key foobar --service-id 123", + WantOutput: "Purged key: foobar (soft: false). Status: ok, ID: 123", + }, + { + Name: "validate PurgeKey API success with soft purge", + API: mock.API{ + PurgeKeyFn: func(_ *fastly.PurgeKeyInput) (*fastly.Purge, error) { + return &fastly.Purge{ + Status: fastly.ToPointer("ok"), + PurgeID: fastly.ToPointer("123"), + }, nil + }, + }, + Args: "--key foobar --service-id 123 --soft", + WantOutput: "Purged key: foobar (soft: true). Status: ok, ID: 123", + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName}, scenarios) +} + +func TestPurgeURL(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate Purge API error", + API: mock.API{ + PurgeFn: func(_ *fastly.PurgeInput) (*fastly.Purge, error) { + return nil, testutil.Err + }, + }, + Args: "--service-id 123 --url https://example.com", + WantError: testutil.Err.Error(), + }, + { + Name: "validate Purge API success", + API: mock.API{ + PurgeFn: func(_ *fastly.PurgeInput) (*fastly.Purge, error) { + return &fastly.Purge{ + Status: fastly.ToPointer("ok"), + PurgeID: fastly.ToPointer("123"), + }, nil + }, + }, + Args: "--service-id 123 --url https://example.com", + WantOutput: "Purged URL: https://example.com (soft: false). Status: ok, ID: 123", + }, + { + Name: "validate Purge API success with soft purge", + API: mock.API{ + PurgeFn: func(_ *fastly.PurgeInput) (*fastly.Purge, error) { + return &fastly.Purge{ + Status: fastly.ToPointer("ok"), + PurgeID: fastly.ToPointer("123"), + }, nil + }, + }, + Args: "--service-id 123 --soft --url https://example.com", + WantOutput: "Purged URL: https://example.com (soft: true). Status: ok, ID: 123", + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName}, scenarios) +} diff --git a/pkg/commands/purge/root.go b/pkg/commands/purge/root.go new file mode 100644 index 000000000..0d4c015d3 --- /dev/null +++ b/pkg/commands/purge/root.go @@ -0,0 +1,249 @@ +package purge + +import ( + "bufio" + "fmt" + "io" + "os" + "path/filepath" + "sort" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/text" +) + +// CommandName is the string to be used to invoke this command. +const CommandName = "purge" + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent argparser.Registerer, g *global.Data) *RootCommand { + var c RootCommand + c.CmdClause = parent.Command(CommandName, "Invalidate objects in the Fastly cache") + c.Globals = g + + // Optional. + c.CmdClause.Flag("all", "Purge everything from a service").BoolVar(&c.all) + c.CmdClause.Flag("file", "Purge a service of a newline delimited list of Surrogate Keys").StringVar(&c.file) + c.CmdClause.Flag("key", "Purge a service of objects tagged with a Surrogate Key").StringVar(&c.key) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + c.CmdClause.Flag("soft", "A 'soft' purge marks affected objects as stale rather than making them inaccessible").BoolVar(&c.soft) + c.CmdClause.Flag("url", "Purge an individual URL").StringVar(&c.url) + + return &c +} + +// RootCommand is the parent command for all subcommands in this package. +// It should be installed under the primary root command. +type RootCommand struct { + argparser.Base + + all bool + file string + key string + serviceName argparser.OptionalServiceNameID + soft bool + url string +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, source, flag, err := argparser.ServiceID(c.serviceName, *c.Globals.Manifest, c.Globals.APIClient, c.Globals.ErrLog) + if err != nil { + return err + } + if c.Globals.Verbose() { + argparser.DisplayServiceID(serviceID, flag, source, out) + } + + // The URL purge API call doesn't require a Service ID. + if c.url == "" { + if source == manifest.SourceUndefined { + return fsterr.ErrNoServiceID + } + } + + if c.all { + if c.soft { + return fsterr.RemediationError{ + Inner: fmt.Errorf("purge-all requests cannot be done in soft mode (--soft) and will always immediately invalidate all cached content associated with the service"), + Remediation: "The --soft flag should not be used with --all so retry command without it.", + } + } + err := c.purgeAll(serviceID, out) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "All": c.all, + }) + return err + } + return nil + } + + if c.file != "" { + err := c.purgeKeys(serviceID, out) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "File": c.file, + }) + return err + } + return nil + } + + if c.key != "" { + err := c.purgeKey(serviceID, out) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Key": c.key, + }) + return err + } + return nil + } + + if c.url != "" { + err := c.purgeURL(out) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "URL": c.url, + }) + return err + } + return nil + } + + return nil +} + +func (c *RootCommand) purgeAll(serviceID string, out io.Writer) error { + p, err := c.Globals.APIClient.PurgeAll(&fastly.PurgeAllInput{ + ServiceID: serviceID, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + }) + return err + } + text.Success(out, "Purge all status: %s", fastly.ToValue(p.Status)) + return nil +} + +func (c *RootCommand) purgeKeys(serviceID string, out io.Writer) error { + keys, err := populateKeys(c.file, c.Globals.ErrLog) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + }) + return err + } + + m, err := c.Globals.APIClient.PurgeKeys(&fastly.PurgeKeysInput{ + ServiceID: serviceID, + Keys: keys, + Soft: c.soft, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Keys": keys, + "Soft": c.soft, + }) + return err + } + + sortedKeys := make([]string, 0, len(m)) + for k := range m { + sortedKeys = append(sortedKeys, k) + } + sort.Strings(sortedKeys) + + t := text.NewTable(out) + t.AddHeader("KEY", "ID") + for _, k := range sortedKeys { + t.AddLine(k, m[k]) + } + t.Print() + + return nil +} + +func (c *RootCommand) purgeKey(serviceID string, out io.Writer) error { + p, err := c.Globals.APIClient.PurgeKey(&fastly.PurgeKeyInput{ + ServiceID: serviceID, + Key: c.key, + Soft: c.soft, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Key": c.key, + "Soft": c.soft, + }) + return err + } + text.Success(out, "Purged key: %s (soft: %t). Status: %s, ID: %s", c.key, c.soft, fastly.ToValue(p.Status), fastly.ToValue(p.PurgeID)) + return nil +} + +func (c *RootCommand) purgeURL(out io.Writer) error { + p, err := c.Globals.APIClient.Purge(&fastly.PurgeInput{ + URL: c.url, + Soft: c.soft, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "URL": c.url, + "Soft": c.soft, + }) + return err + } + text.Success(out, "Purged URL: %s (soft: %t). Status: %s, ID: %s", c.url, c.soft, fastly.ToValue(p.Status), fastly.ToValue(p.PurgeID)) + return nil +} + +// populateKeys opens the given file path, initializes a scanner, and appends +// each line of the file (expected to be a surrogate key) to a slice. +func populateKeys(fpath string, errLog fsterr.LogInterface) (keys []string, err error) { + var ( + file io.Reader + path string + ) + + if path, err = filepath.Abs(fpath); err == nil { + if _, err = os.Stat(path); err == nil { + if file, err = os.Open(path); err == nil /* #nosec */ { + scanner := bufio.NewScanner(file) + for scanner.Scan() { + keys = append(keys, scanner.Text()) + } + err = scanner.Err() + } + } + } + + if err != nil { + errLog.Add(err) + return nil, err + } + return keys, nil +} diff --git a/pkg/commands/purge/testdata/keys b/pkg/commands/purge/testdata/keys new file mode 100644 index 000000000..86e041dad --- /dev/null +++ b/pkg/commands/purge/testdata/keys @@ -0,0 +1,3 @@ +foo +bar +baz diff --git a/pkg/commands/ratelimit/create.go b/pkg/commands/ratelimit/create.go new file mode 100644 index 000000000..56071bb25 --- /dev/null +++ b/pkg/commands/ratelimit/create.go @@ -0,0 +1,270 @@ +package ratelimit + +import ( + "errors" + "fmt" + "io" + "strings" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// rateLimitActionFlagOpts is a string representation of rateLimitActions +// suitable for use within the enum flag definition below. +var rateLimitActionFlagOpts = func() (actions []string) { + for _, a := range fastly.ERLActions { + actions = append(actions, string(a)) + } + return actions +}() + +// rateLimitLoggerFlagOpts is a string representation of rateLimitLoggers +// suitable for use within the enum flag definition below. +var rateLimitLoggerFlagOpts = func() (loggers []string) { + for _, l := range fastly.ERLLoggers { + loggers = append(loggers, string(l)) + } + return loggers +}() + +// rateLimitWindowSizeFlagOpts is a string representation of rateLimitWindowSizes +// suitable for use within the enum flag definition below. +var rateLimitWindowSizeFlagOpts = func() (windowSizes []string) { + for _, w := range fastly.ERLWindowSizes { + windowSizes = append(windowSizes, fmt.Sprint(w)) + } + return windowSizes +}() + +// NewCreateCommand returns a usable command registered under the parent. +func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { + c := CreateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + + c.CmdClause = parent.Command("create", "Create a rate limiter for a particular service and version").Alias("add") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.CmdClause.Flag("action", "The action to take when a rate limiter violation is detected").HintOptions(rateLimitActionFlagOpts...).EnumVar(&c.action, rateLimitActionFlagOpts...) + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.autoClone.Set, + Dst: &c.autoClone.Value, + }) + c.CmdClause.Flag("client-key", "Comma-separated list of VCL variable used to generate a counter key to identify a client").StringVar(&c.clientKeys) + c.CmdClause.Flag("feature-revision", "Revision number of the rate limiting feature implementation").IntVar(&c.featRevision) + c.CmdClause.Flag("http-methods", "Comma-separated list of HTTP methods to apply rate limiting to").StringVar(&c.httpMethods) + c.RegisterFlagBool(c.JSONFlag()) // --json + c.CmdClause.Flag("logger-type", "Name of the type of logging endpoint to be used when action is `log_only`").HintOptions(rateLimitLoggerFlagOpts...).EnumVar(&c.loggerType, rateLimitLoggerFlagOpts...) + c.CmdClause.Flag("name", "A human readable name for the rate limiting rule").StringVar(&c.name) + c.CmdClause.Flag("penalty-box-dur", "Length of time in minutes that the rate limiter is in effect after the initial violation is detected").IntVar(&c.penaltyDuration) + c.CmdClause.Flag("response-content", "HTTP response body data").StringVar(&c.responseContent) + c.CmdClause.Flag("response-content-type", "HTTP Content-Type (e.g. application/json)").StringVar(&c.responseContentType) + c.CmdClause.Flag("response-object-name", "Name of existing response object. Required if action is response_object").StringVar(&c.responseObjectName) + c.CmdClause.Flag("response-status", "HTTP response status code (e.g. 429)").IntVar(&c.responseStatus) + c.CmdClause.Flag("rps-limit", "Upper limit of requests per second allowed by the rate limiter").IntVar(&c.rpsLimit) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + c.CmdClause.Flag("uri-dict-name", "The name of an Edge Dictionary containing URIs as keys").StringVar(&c.uriDictName) + c.CmdClause.Flag("window-size", "Number of seconds during which the RPS limit must be exceeded in order to trigger a violation").HintOptions(rateLimitWindowSizeFlagOpts...).EnumVar(&c.windowSize, rateLimitWindowSizeFlagOpts...) + + return &c +} + +// CreateCommand calls the Fastly API to create an appropriate resource. +type CreateCommand struct { + argparser.Base + argparser.JSONOutput + + action string + autoClone argparser.OptionalAutoClone + clientKeys string + featRevision int + httpMethods string + loggerType string + name string + penaltyDuration int + responseContent string + responseContentType string + responseObjectName string + responseStatus int + rpsLimit int + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion + uriDictName string + windowSize string +} + +// Exec invokes the application logic for the command. +func (c *CreateCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + if err := c.responseFlagValidator(); err != nil { + return fsterr.RemediationError{ + Inner: err, + Remediation: "When defining a response, all response flags (--response-content, --response-content-type, --response-status) should be set", + } + } + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.autoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fsterr.ServiceVersion(serviceVersion), + }) + return err + } + + input := c.constructInput() + input.ServiceID = serviceID + input.ServiceVersion = fastly.ToValue(serviceVersion.Number) + + o, err := c.Globals.APIClient.CreateERL(input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fsterr.ServiceVersion(serviceVersion), + }) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + text.Success(out, "Created rate limiter '%s' (%s)", fastly.ToValue(o.Name), fastly.ToValue(o.RateLimiterID)) + return nil +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *CreateCommand) constructInput() *fastly.CreateERLInput { + var input fastly.CreateERLInput + + if c.action != "" { + for _, a := range fastly.ERLActions { + if c.action == string(a) { + input.Action = fastly.ToPointer(a) + break + } + } + } + + if c.clientKeys != "" { + clientKeys := strings.Split(strings.ReplaceAll(c.clientKeys, " ", ""), ",") + input.ClientKey = &clientKeys + } + + if c.featRevision > 0 { + input.FeatureRevision = fastly.ToPointer(c.featRevision) + } + + if c.httpMethods != "" { + httpMethods := strings.Split(strings.ReplaceAll(c.httpMethods, " ", ""), ",") + input.HTTPMethods = &httpMethods + } + + if c.loggerType != "" { + for _, l := range fastly.ERLLoggers { + if c.loggerType == string(l) { + input.LoggerType = fastly.ToPointer(l) + break + } + } + } + + if c.name != "" { + input.Name = fastly.ToPointer(c.name) + } + + if c.penaltyDuration > 0 { + input.PenaltyBoxDuration = fastly.ToPointer(c.penaltyDuration) + } + + if c.responseContent != "" && c.responseContentType != "" && c.responseStatus > 0 { + input.Response = &fastly.ERLResponseType{ + ERLContent: fastly.ToPointer(c.responseContent), + ERLContentType: fastly.ToPointer(c.responseContentType), + ERLStatus: fastly.ToPointer(c.responseStatus), + } + } + + if c.responseObjectName != "" { + input.ResponseObjectName = fastly.ToPointer(c.responseObjectName) + } + + if c.rpsLimit > 0 { + input.RpsLimit = fastly.ToPointer(c.rpsLimit) + } + + if c.uriDictName != "" { + input.URIDictionaryName = fastly.ToPointer(c.uriDictName) + } + + if c.windowSize != "" { + for _, w := range fastly.ERLWindowSizes { + if c.windowSize == fmt.Sprint(w) { + input.WindowSize = fastly.ToPointer(w) + break + } + } + } + + return &input +} + +// responseFlagValidator ensures if a user specifies one of the response flags, +// that they must specify ALL of the response flags. +func (c *CreateCommand) responseFlagValidator() error { + var state int + if c.responseContent != "" { + state++ + } + if c.responseContentType != "" { + state++ + } + if c.responseStatus > 0 { + state++ + } + if state > 0 && state < 3 { + return errors.New("invalid flag use") + } + return nil +} diff --git a/pkg/commands/ratelimit/delete.go b/pkg/commands/ratelimit/delete.go new file mode 100644 index 000000000..ef81d89a0 --- /dev/null +++ b/pkg/commands/ratelimit/delete.go @@ -0,0 +1,55 @@ +package ratelimit + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// NewDeleteCommand returns a usable command registered under the parent. +func NewDeleteCommand(parent argparser.Registerer, globals *global.Data) *DeleteCommand { + var c DeleteCommand + c.CmdClause = parent.Command("delete", "Delete a rate limiter by its ID").Alias("remove") + c.Globals = globals + + // Required. + c.CmdClause.Flag("id", "Alphanumeric string identifying the rate limiter").Required().StringVar(&c.id) + + return &c +} + +// DeleteCommand calls the Fastly API to delete an appropriate resource. +type DeleteCommand struct { + argparser.Base + + id string +} + +// Exec invokes the application logic for the command. +func (c *DeleteCommand) Exec(_ io.Reader, out io.Writer) error { + input := c.constructInput() + + err := c.Globals.APIClient.DeleteERL(input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "User ID": c.id, + }) + return err + } + + text.Success(out, "Deleted rate limiter '%s'", c.id) + return nil +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *DeleteCommand) constructInput() *fastly.DeleteERLInput { + var input fastly.DeleteERLInput + + input.ERLID = c.id + + return &input +} diff --git a/pkg/commands/ratelimit/describe.go b/pkg/commands/ratelimit/describe.go new file mode 100644 index 000000000..32816153d --- /dev/null +++ b/pkg/commands/ratelimit/describe.go @@ -0,0 +1,103 @@ +package ratelimit + +import ( + "fmt" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" +) + +// NewDescribeCommand returns a usable command registered under the parent. +func NewDescribeCommand(parent argparser.Registerer, g *global.Data) *DescribeCommand { + var c DescribeCommand + c.CmdClause = parent.Command("describe", "Get a rate limiter by its ID").Alias("get") + c.Globals = g + + // Required. + c.CmdClause.Flag("id", "Alphanumeric string identifying the rate limiter").Required().StringVar(&c.id) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + + return &c +} + +// DescribeCommand calls the Fastly API to describe an appropriate resource. +type DescribeCommand struct { + argparser.Base + argparser.JSONOutput + + id string +} + +// Exec invokes the application logic for the command. +func (c *DescribeCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + o, err := c.Globals.APIClient.GetERL(c.constructInput()) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + c.print(out, o) + return nil +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *DescribeCommand) constructInput() *fastly.GetERLInput { + var input fastly.GetERLInput + input.ERLID = c.id + return &input +} + +// print displays the information returned from the API. +func (c *DescribeCommand) print(out io.Writer, o *fastly.ERL) { + fmt.Fprintf(out, "\nAction: %+v\n", fastly.ToValue(o.Action)) + fmt.Fprintf(out, "Client Key: %+v\n", o.ClientKey) + fmt.Fprintf(out, "Feature Revision: %+v\n", fastly.ToValue(o.FeatureRevision)) + fmt.Fprintf(out, "HTTP Methods: %+v\n", o.HTTPMethods) + fmt.Fprintf(out, "ID: %+v\n", fastly.ToValue(o.RateLimiterID)) + fmt.Fprintf(out, "Logger Type: %+v\n", fastly.ToValue(o.LoggerType)) + fmt.Fprintf(out, "Name: %+v\n", fastly.ToValue(o.Name)) + fmt.Fprintf(out, "Penalty Box Duration: %+v\n", fastly.ToValue(o.PenaltyBoxDuration)) + fmt.Fprintf(out, "Response: %+v\n", parseResponse(o.Response)) + fmt.Fprintf(out, "Response Object Name: %+v\n", fastly.ToValue(o.ResponseObjectName)) + fmt.Fprintf(out, "RPS Limit: %+v\n", fastly.ToValue(o.RpsLimit)) + fmt.Fprintf(out, "Service ID: %+v\n", fastly.ToValue(o.ServiceID)) + fmt.Fprintf(out, "URI Dictionary Name: %+v\n", fastly.ToValue(o.URIDictionaryName)) + fmt.Fprintf(out, "Version: %+v\n", fastly.ToValue(o.Version)) + fmt.Fprintf(out, "WindowSize: %+v\n", fastly.ToValue(o.WindowSize)) + + if o.CreatedAt != nil { + fmt.Fprintf(out, "Created at: %s\n", o.CreatedAt) + } + if o.UpdatedAt != nil { + fmt.Fprintf(out, "Updated at: %s\n", o.UpdatedAt) + } + if o.DeletedAt != nil { + fmt.Fprintf(out, "Deleted at: %s\n", o.DeletedAt) + } +} + +func parseResponse(r *fastly.ERLResponse) string { + if r != nil { + return fmt.Sprintf( + `{ERLContent:%v ERLContentType:%v ERLStatus:%v}`, + fastly.ToValue(r.ERLContent), + fastly.ToValue(r.ERLContentType), + fastly.ToValue(r.ERLStatus), + ) + } + return "" +} diff --git a/pkg/commands/ratelimit/doc.go b/pkg/commands/ratelimit/doc.go new file mode 100644 index 000000000..5511f58a5 --- /dev/null +++ b/pkg/commands/ratelimit/doc.go @@ -0,0 +1,3 @@ +// Package ratelimit contains commands to inspect and manipulate Fastly edge +// rate limiters. +package ratelimit diff --git a/pkg/commands/ratelimit/list.go b/pkg/commands/ratelimit/list.go new file mode 100644 index 000000000..01bf22569 --- /dev/null +++ b/pkg/commands/ratelimit/list.go @@ -0,0 +1,152 @@ +package ratelimit + +import ( + "fmt" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// NewListCommand returns a usable command registered under the parent. +func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { + var c ListCommand + c.CmdClause = parent.Command("list", "List all rate limiters for a particular service and version") + c.Globals = g + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + return &c +} + +// ListCommand calls the Fastly API to list appropriate resources. +type ListCommand struct { + argparser.Base + argparser.JSONOutput + + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion +} + +// Exec invokes the application logic for the command. +func (c *ListCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fsterr.ServiceVersion(serviceVersion), + }) + return err + } + + input := &fastly.ListERLsInput{ + ServiceID: serviceID, + ServiceVersion: fastly.ToValue(serviceVersion.Number), + } + + o, err := c.Globals.APIClient.ListERLs(input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fastly.ToValue(serviceVersion.Number), + }) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + if c.Globals.Verbose() { + c.printVerbose(out, o) + } else { + c.printSummary(out, o) + } + return nil +} + +// printVerbose displays the information returned from the API in a verbose +// format. +func (c *ListCommand) printVerbose(out io.Writer, o []*fastly.ERL) { + for _, u := range o { + fmt.Fprintf(out, "\nAction: %+v\n", fastly.ToValue(u.Action)) + fmt.Fprintf(out, "Client Key: %+v\n", u.ClientKey) + fmt.Fprintf(out, "Feature Revision: %+v\n", fastly.ToValue(u.FeatureRevision)) + fmt.Fprintf(out, "HTTP Methods: %+v\n", u.HTTPMethods) + fmt.Fprintf(out, "ID: %+v\n", fastly.ToValue(u.RateLimiterID)) + fmt.Fprintf(out, "Logger Type: %+v\n", fastly.ToValue(u.LoggerType)) + fmt.Fprintf(out, "Name: %+v\n", fastly.ToValue(u.Name)) + fmt.Fprintf(out, "Penalty Box Duration: %+v\n", fastly.ToValue(u.PenaltyBoxDuration)) + if u.Response != nil { + fmt.Fprintf(out, "Response: %+v\n", *u.Response) + } + fmt.Fprintf(out, "Response Object Name: %+v\n", fastly.ToValue(u.ResponseObjectName)) + fmt.Fprintf(out, "RPS Limit: %+v\n", fastly.ToValue(u.RpsLimit)) + fmt.Fprintf(out, "Service ID: %+v\n", fastly.ToValue(u.ServiceID)) + fmt.Fprintf(out, "URI Dictionary Name: %+v\n", fastly.ToValue(u.URIDictionaryName)) + fmt.Fprintf(out, "Version: %+v\n", fastly.ToValue(u.Version)) + fmt.Fprintf(out, "WindowSize: %+v\n", fastly.ToValue(u.WindowSize)) + if u.CreatedAt != nil { + fmt.Fprintf(out, "Created at: %s\n", u.CreatedAt) + } + if u.UpdatedAt != nil { + fmt.Fprintf(out, "Updated at: %s\n", u.UpdatedAt) + } + if u.DeletedAt != nil { + fmt.Fprintf(out, "Deleted at: %s\n", u.DeletedAt) + } + } +} + +// printSummary displays the information returned from the API in a summarised +// format. +func (c *ListCommand) printSummary(out io.Writer, o []*fastly.ERL) { + t := text.NewTable(out) + t.AddHeader("ID", "NAME", "ACTION", "RPS LIMIT", "WINDOW SIZE", "PENALTY BOX DURATION") + for _, u := range o { + t.AddLine( + fastly.ToValue(u.RateLimiterID), + fastly.ToValue(u.Name), + fastly.ToValue(u.Action), + fastly.ToValue(u.RpsLimit), + fastly.ToValue(u.WindowSize), + fastly.ToValue(u.PenaltyBoxDuration), + ) + } + t.Print() +} diff --git a/pkg/commands/ratelimit/ratelimit_test.go b/pkg/commands/ratelimit/ratelimit_test.go new file mode 100644 index 000000000..668548e03 --- /dev/null +++ b/pkg/commands/ratelimit/ratelimit_test.go @@ -0,0 +1,172 @@ +package ratelimit_test + +import ( + "testing" + + "github.com/fastly/go-fastly/v10/fastly" + + root "github.com/fastly/cli/pkg/commands/ratelimit" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" +) + +func TestRateLimitCreate(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate CreateERL API error", + API: mock.API{ + CreateERLFn: func(_ *fastly.CreateERLInput) (*fastly.ERL, error) { + return nil, testutil.Err + }, + ListVersionsFn: testutil.ListVersions, + }, + Args: "--name example --service-id 123 --version 3", + WantError: testutil.Err.Error(), + }, + { + Name: "validate CreateERL API success", + API: mock.API{ + CreateERLFn: func(i *fastly.CreateERLInput) (*fastly.ERL, error) { + return &fastly.ERL{ + Name: i.Name, + RateLimiterID: fastly.ToPointer("123"), + }, nil + }, + ListVersionsFn: testutil.ListVersions, + }, + Args: "--name example --service-id 123 --version 3", + WantOutput: "Created rate limiter 'example' (123)", + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, "create"}, scenarios) +} + +func TestRateLimitDelete(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate DeleteERL API error", + API: mock.API{ + DeleteERLFn: func(_ *fastly.DeleteERLInput) error { + return testutil.Err + }, + }, + Args: "--id 123", + WantError: testutil.Err.Error(), + }, + { + Name: "validate DeleteERL API success", + API: mock.API{ + DeleteERLFn: func(_ *fastly.DeleteERLInput) error { + return nil + }, + }, + Args: "--id 123", + WantOutput: "SUCCESS: Deleted rate limiter '123'\n", + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, "delete"}, scenarios) +} + +func TestRateLimitDescribe(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate GetERL API error", + API: mock.API{ + GetERLFn: func(_ *fastly.GetERLInput) (*fastly.ERL, error) { + return nil, testutil.Err + }, + }, + Args: "--id 123", + WantError: testutil.Err.Error(), + }, + { + Name: "validate ListERL API success", + API: mock.API{ + GetERLFn: func(_ *fastly.GetERLInput) (*fastly.ERL, error) { + return &fastly.ERL{ + RateLimiterID: fastly.ToPointer("123"), + Name: fastly.ToPointer("example"), + Action: fastly.ToPointer(fastly.ERLActionResponse), + RpsLimit: fastly.ToPointer(10), + WindowSize: fastly.ToPointer(fastly.ERLSize60), + PenaltyBoxDuration: fastly.ToPointer(20), + }, nil + }, + }, + Args: "--id 123", + WantOutput: "\nAction: response\nClient Key: []\nFeature Revision: 0\nHTTP Methods: []\nID: 123\nLogger Type: \nName: example\nPenalty Box Duration: 20\nResponse: \nResponse Object Name: \nRPS Limit: 10\nService ID: \nURI Dictionary Name: \nVersion: 0\nWindowSize: 60\n", + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, "describe"}, scenarios) +} + +func TestRateLimitList(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate ListERL API error", + API: mock.API{ + ListERLsFn: func(_ *fastly.ListERLsInput) ([]*fastly.ERL, error) { + return nil, testutil.Err + }, + ListVersionsFn: testutil.ListVersions, + }, + Args: "--service-id 123 --version 3", + WantError: testutil.Err.Error(), + }, + { + Name: "validate ListERL API success", + API: mock.API{ + ListERLsFn: func(_ *fastly.ListERLsInput) ([]*fastly.ERL, error) { + return []*fastly.ERL{ + { + RateLimiterID: fastly.ToPointer("123"), + Name: fastly.ToPointer("example"), + Action: fastly.ToPointer(fastly.ERLActionResponse), + RpsLimit: fastly.ToPointer(10), + WindowSize: fastly.ToPointer(fastly.ERLSize60), + PenaltyBoxDuration: fastly.ToPointer(20), + }, + }, nil + }, + ListVersionsFn: testutil.ListVersions, + }, + Args: "--service-id 123 --version 3", + WantOutput: "ID NAME ACTION RPS LIMIT WINDOW SIZE PENALTY BOX DURATION\n123 example response 10 60 20\n", + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, "list"}, scenarios) +} + +func TesRateLimittUpdate(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate UpdateERL API error", + API: mock.API{ + UpdateERLFn: func(_ *fastly.UpdateERLInput) (*fastly.ERL, error) { + return nil, testutil.Err + }, + }, + Args: "--id 123 --name example", + WantError: testutil.Err.Error(), + }, + { + Name: "validate UpdateERL API success", + API: mock.API{ + UpdateERLFn: func(i *fastly.UpdateERLInput) (*fastly.ERL, error) { + return &fastly.ERL{ + Name: i.Name, + RateLimiterID: fastly.ToPointer("123"), + }, nil + }, + }, + Args: "--id 123 --name example", + WantOutput: "Updated rate limiter 'example' (123)", + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, "update"}, scenarios) +} diff --git a/pkg/commands/ratelimit/root.go b/pkg/commands/ratelimit/root.go new file mode 100644 index 000000000..5ddf2abe1 --- /dev/null +++ b/pkg/commands/ratelimit/root.go @@ -0,0 +1,31 @@ +package ratelimit + +import ( + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" +) + +// RootCommand is the parent command for all subcommands in this package. +// It should be installed under the primary root command. +type RootCommand struct { + argparser.Base + // no flags +} + +// CommandName is the string to be used to invoke this command. +const CommandName = "rate-limit" + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent argparser.Registerer, g *global.Data) *RootCommand { + var c RootCommand + c.Globals = g + c.CmdClause = parent.Command(CommandName, "Manipulate rate-limiters of the Fastly API and web interface") + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { + panic("unreachable") +} diff --git a/pkg/commands/ratelimit/update.go b/pkg/commands/ratelimit/update.go new file mode 100644 index 000000000..ea8ed2b17 --- /dev/null +++ b/pkg/commands/ratelimit/update.go @@ -0,0 +1,197 @@ +package ratelimit + +import ( + "errors" + "fmt" + "io" + "strings" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// NewUpdateCommand returns a usable command registered under the parent. +func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { + c := UpdateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + + c.CmdClause = parent.Command("update", "Update a rate limiter by its ID") + + // Required. + c.CmdClause.Flag("id", "Alphanumeric string identifying the rate limiter").Required().StringVar(&c.id) + + // Optional. + c.CmdClause.Flag("action", "The action to take when a rate limiter violation is detected").HintOptions(rateLimitActionFlagOpts...).EnumVar(&c.action, rateLimitActionFlagOpts...) + c.CmdClause.Flag("client-key", "Comma-separated list of VCL variable used to generate a counter key to identify a client").StringVar(&c.clientKeys) + c.CmdClause.Flag("feature-revision", "Revision number of the rate limiting feature implementation").IntVar(&c.featRevision) + c.CmdClause.Flag("http-methods", "Comma-separated list of HTTP methods to apply rate limiting to").StringVar(&c.httpMethods) + c.RegisterFlagBool(c.JSONFlag()) // --json + c.CmdClause.Flag("logger-type", "Name of the type of logging endpoint to be used when action is `log_only`").HintOptions(rateLimitLoggerFlagOpts...).EnumVar(&c.loggerType, rateLimitLoggerFlagOpts...) + c.CmdClause.Flag("name", "A human readable name for the rate limiting rule").StringVar(&c.name) + c.CmdClause.Flag("penalty-box-dur", "Length of time in minutes that the rate limiter is in effect after the initial violation is detected").IntVar(&c.penaltyDuration) + c.CmdClause.Flag("response-content", "HTTP response body data").StringVar(&c.responseContent) + c.CmdClause.Flag("response-content-type", "HTTP Content-Type (e.g. application/json)").StringVar(&c.responseContentType) + c.CmdClause.Flag("response-object-name", "Name of existing response object. Required if action is response_object").StringVar(&c.responseObjectName) + c.CmdClause.Flag("response-status", "HTTP response status code (e.g. 429)").IntVar(&c.responseStatus) + c.CmdClause.Flag("rps-limit", "Upper limit of requests per second allowed by the rate limiter").IntVar(&c.rpsLimit) + c.CmdClause.Flag("uri-dict-name", "The name of an Edge Dictionary containing URIs as keys").StringVar(&c.uriDictName) + c.CmdClause.Flag("window-size", "Number of seconds during which the RPS limit must be exceeded in order to trigger a violation").HintOptions(rateLimitWindowSizeFlagOpts...).EnumVar(&c.windowSize, rateLimitWindowSizeFlagOpts...) + + return &c +} + +// UpdateCommand calls the Fastly API to create an appropriate resource. +type UpdateCommand struct { + argparser.Base + argparser.JSONOutput + + action string + clientKeys string + featRevision int + httpMethods string + id string + loggerType string + name string + penaltyDuration int + responseContent string + responseContentType string + responseObjectName string + responseStatus int + rpsLimit int + uriDictName string + windowSize string +} + +// Exec invokes the application logic for the command. +func (c *UpdateCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + if err := c.responseFlagValidator(); err != nil { + return fsterr.RemediationError{ + Inner: err, + Remediation: "When updating a response, all response flags (--response-content, --response-content-type, --response-status) should be set", + } + } + + input := c.constructInput() + o, err := c.Globals.APIClient.UpdateERL(input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + text.Success(out, "Updated rate limiter '%s' (%s)", fastly.ToValue(o.Name), fastly.ToValue(o.RateLimiterID)) + return nil +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *UpdateCommand) constructInput() *fastly.UpdateERLInput { + var input fastly.UpdateERLInput + input.ERLID = c.id + + // NOTE: rateLimitActions is defined in ./create.go + if c.action != "" { + for _, a := range fastly.ERLActions { + if c.action == string(a) { + input.Action = fastly.ToPointer(a) + break + } + } + } + + if c.clientKeys != "" { + clientKeys := strings.Split(strings.ReplaceAll(c.clientKeys, " ", ""), ",") + input.ClientKey = &clientKeys + } + + if c.featRevision > 0 { + input.FeatureRevision = fastly.ToPointer(c.featRevision) + } + + if c.httpMethods != "" { + httpMethods := strings.Split(strings.ReplaceAll(c.httpMethods, " ", ""), ",") + input.HTTPMethods = &httpMethods + } + + // NOTE: rateLimitLoggers is defined in ./create.go + if c.loggerType != "" { + for _, l := range fastly.ERLLoggers { + if c.loggerType == string(l) { + input.LoggerType = fastly.ToPointer(l) + break + } + } + } + + if c.name != "" { + input.Name = fastly.ToPointer(c.name) + } + + if c.penaltyDuration > 0 { + input.PenaltyBoxDuration = fastly.ToPointer(c.penaltyDuration) + } + + if c.responseContent != "" && c.responseContentType != "" && c.responseStatus > 0 { + input.Response = &fastly.ERLResponseType{ + ERLContent: fastly.ToPointer(c.responseContent), + ERLContentType: fastly.ToPointer(c.responseContentType), + ERLStatus: fastly.ToPointer(c.responseStatus), + } + } + + if c.responseObjectName != "" { + input.ResponseObjectName = fastly.ToPointer(c.responseObjectName) + } + + if c.rpsLimit > 0 { + input.RpsLimit = fastly.ToPointer(c.rpsLimit) + } + + if c.uriDictName != "" { + input.URIDictionaryName = fastly.ToPointer(c.uriDictName) + } + + // NOTE: rateLimitWindowSizes is defined in ./create.go + if c.windowSize != "" { + for _, w := range fastly.ERLWindowSizes { + if c.windowSize == fmt.Sprint(w) { + input.WindowSize = fastly.ToPointer(w) + break + } + } + } + + return &input +} + +// responseFlagValidator ensures if a user specifies one of the response flags, +// that they must specify ALL of the response flags. +func (c *UpdateCommand) responseFlagValidator() error { + var state int + if c.responseContent != "" { + state++ + } + if c.responseContentType != "" { + state++ + } + if c.responseStatus > 0 { + state++ + } + if state > 0 && state < 3 { + return errors.New("invalid flag use") + } + return nil +} diff --git a/pkg/commands/resourcelink/create.go b/pkg/commands/resourcelink/create.go new file mode 100644 index 000000000..5037a6c30 --- /dev/null +++ b/pkg/commands/resourcelink/create.go @@ -0,0 +1,136 @@ +package resourcelink + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// CreateCommand calls the Fastly API to create a resource link. +type CreateCommand struct { + argparser.Base + argparser.JSONOutput + + autoClone argparser.OptionalAutoClone + input fastly.CreateResourceInput + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion +} + +// NewCreateCommand returns a usable command registered under the parent. +func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { + c := CreateCommand{ + Base: argparser.Base{ + Globals: g, + }, + input: fastly.CreateResourceInput{ + // Kingpin requires the following to be initialized. + ResourceID: new(string), + Name: new(string), + }, + } + c.CmdClause = parent.Command("create", "Create a Fastly service resource link").Alias("link") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: "resource-id", + Short: 'r', + Description: flagResourceIDDescription, + Dst: c.input.ResourceID, + Required: true, + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // At least one of the following is required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Short: 's', + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceName, + Action: c.serviceName.Set, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + + // Optional. + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.autoClone.Set, + Dst: &c.autoClone.Value, + }) + c.RegisterFlagBool(c.JSONFlag()) // --json + c.RegisterFlag(argparser.StringFlagOpts{ + Name: "name", + Short: 'n', + Description: flagNameDescription, + Dst: c.input.Name, + }) + + return &c +} + +// Exec invokes the application logic for the command. +func (c *CreateCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.autoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": c.Globals.Manifest.Flag.ServiceID, + "Service Version": fsterr.ServiceVersion(serviceVersion), + }) + return err + } + + c.input.ServiceID = serviceID + c.input.ServiceVersion = fastly.ToValue(serviceVersion.Number) + + o, err := c.Globals.APIClient.CreateResource(&c.input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "ID": c.input.ResourceID, + "Service ID": c.input.ServiceID, + "Service Version": c.input.ServiceVersion, + }) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + text.Success(out, + "Created service resource link %q (%s) on service %s version %d", + fastly.ToValue(o.Name), + fastly.ToValue(o.LinkID), + fastly.ToValue(o.ServiceID), + fastly.ToValue(o.ServiceVersion), + ) + return nil +} diff --git a/pkg/commands/resourcelink/delete.go b/pkg/commands/resourcelink/delete.go new file mode 100644 index 000000000..0dd6ae708 --- /dev/null +++ b/pkg/commands/resourcelink/delete.go @@ -0,0 +1,130 @@ +package resourcelink + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// DeleteCommand calls the Fastly API to delete service resource links. +type DeleteCommand struct { + argparser.Base + argparser.JSONOutput + + autoClone argparser.OptionalAutoClone + input fastly.DeleteResourceInput + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion +} + +// NewDeleteCommand returns a usable command registered under the parent. +func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { + c := DeleteCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("delete", "Delete a resource link for a Fastly service version").Alias("remove") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: "id", + Description: flagIDDescription, + Dst: &c.input.ResourceID, + Required: true, + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // At least one of the following is required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Short: 's', + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceName, + Action: c.serviceName.Set, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + + // Optional. + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.autoClone.Set, + Dst: &c.autoClone.Value, + }) + c.RegisterFlagBool(c.JSONFlag()) // --json + + return &c +} + +// Exec invokes the application logic for the command. +func (c *DeleteCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.autoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": c.Globals.Manifest.Flag.ServiceID, + "Service Version": fsterr.ServiceVersion(serviceVersion), + }) + return err + } + + c.input.ServiceID = serviceID + c.input.ServiceVersion = fastly.ToValue(serviceVersion.Number) + + err = c.Globals.APIClient.DeleteResource(&c.input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "ID": c.input.ResourceID, + "Service ID": c.input.ServiceID, + "Service Version": c.input.ServiceVersion, + }) + return err + } + + if c.JSONOutput.Enabled { + o := struct { + ID string `json:"id"` + ServiceID string `json:"service_id"` + ServiceVersion int `json:"service_version"` + Deleted bool `json:"deleted"` + }{ + c.input.ResourceID, + c.input.ServiceID, + c.input.ServiceVersion, + true, + } + _, err := c.WriteJSON(out, o) + return err + } + + text.Success(out, "Deleted service resource link %s from service %s version %d", c.input.ResourceID, c.input.ServiceID, c.input.ServiceVersion) + return nil +} diff --git a/pkg/commands/resourcelink/describe.go b/pkg/commands/resourcelink/describe.go new file mode 100644 index 000000000..614280462 --- /dev/null +++ b/pkg/commands/resourcelink/describe.go @@ -0,0 +1,110 @@ +package resourcelink + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// DescribeCommand calls the Fastly API to describe a service resource link. +type DescribeCommand struct { + argparser.Base + argparser.JSONOutput + + input fastly.GetResourceInput + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion +} + +// NewDescribeCommand returns a usable command registered under the parent. +func NewDescribeCommand(parent argparser.Registerer, g *global.Data) *DescribeCommand { + c := DescribeCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("describe", "Show detailed information about a Fastly service resource link").Alias("get") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: "id", + Description: flagIDDescription, + Dst: &c.input.ResourceID, + Required: true, + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // At least one of the following is required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Short: 's', + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceName, + Action: c.serviceName.Set, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + + return &c +} + +// Exec invokes the application logic for the command. +func (c *DescribeCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + serviceID, source, flag, err := argparser.ServiceID(c.serviceName, *c.Globals.Manifest, c.Globals.APIClient, c.Globals.ErrLog) + if err != nil { + return err + } + if c.Globals.Verbose() { + argparser.DisplayServiceID(serviceID, flag, source, out) + } + + serviceVersion, err := c.serviceVersion.Parse(serviceID, c.Globals.APIClient) + if err != nil { + return err + } + + c.input.ServiceID = serviceID + c.input.ServiceVersion = fastly.ToValue(serviceVersion.Number) + + o, err := c.Globals.APIClient.GetResource(&c.input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "ID": c.input.ResourceID, + "Service ID": c.input.ServiceID, + "Service Version": c.input.ServiceVersion, + }) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + if !c.Globals.Verbose() { + text.Output(out, "Service ID: %s", fastly.ToValue(o.ServiceID)) + } + text.Output(out, "Service Version: %d", fastly.ToValue(o.ServiceVersion)) + text.PrintResource(out, "", o) + + return nil +} diff --git a/pkg/commands/resourcelink/doc.go b/pkg/commands/resourcelink/doc.go new file mode 100644 index 000000000..56ae86e5a --- /dev/null +++ b/pkg/commands/resourcelink/doc.go @@ -0,0 +1,4 @@ +// Package resourcelink contains commands to inspect and +// manipulate service resource links. +// https://www.fastly.com/documentation/reference/api/services/resource +package resourcelink diff --git a/pkg/commands/resourcelink/list.go b/pkg/commands/resourcelink/list.go new file mode 100644 index 000000000..eceec87bd --- /dev/null +++ b/pkg/commands/resourcelink/list.go @@ -0,0 +1,109 @@ +package resourcelink + +import ( + "fmt" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// ListCommand calls the Fastly API to list service resource links. +type ListCommand struct { + argparser.Base + argparser.JSONOutput + + input fastly.ListResourcesInput + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion +} + +// NewListCommand returns a usable command registered under the parent. +func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { + c := ListCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("list", "List all resource links for a Fastly service version") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // At least one of the following is required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Short: 's', + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceName, + Action: c.serviceName.Set, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + + return &c +} + +// Exec invokes the application logic for the command. +func (c *ListCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + serviceID, source, flag, err := argparser.ServiceID(c.serviceName, *c.Globals.Manifest, c.Globals.APIClient, c.Globals.ErrLog) + if err != nil { + return err + } + if c.Globals.Verbose() { + argparser.DisplayServiceID(serviceID, flag, source, out) + } + + serviceVersion, err := c.serviceVersion.Parse(serviceID, c.Globals.APIClient) + if err != nil { + return err + } + + c.input.ServiceID = serviceID + c.input.ServiceVersion = fastly.ToValue(serviceVersion.Number) + + o, err := c.Globals.APIClient.ListResources(&c.input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": c.input.ServiceID, + "Service Version": c.input.ServiceVersion, + }) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + if !c.Globals.Verbose() { + fmt.Fprintf(out, "Service ID: %s\n", c.input.ServiceID) + } + text.Output(out, "Service Version: %d\n", c.input.ServiceVersion) + + for i, resource := range o { + fmt.Fprintf(out, "Resource Link %d/%d\n", i+1, len(o)) + text.PrintResource(out, "\t", resource) + fmt.Fprintln(out) + } + + return nil +} diff --git a/pkg/commands/resourcelink/resourcelink_test.go b/pkg/commands/resourcelink/resourcelink_test.go new file mode 100644 index 000000000..283451e99 --- /dev/null +++ b/pkg/commands/resourcelink/resourcelink_test.go @@ -0,0 +1,628 @@ +package resourcelink_test + +import ( + "bytes" + "fmt" + "io" + "strings" + "testing" + "time" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/app" + "github.com/fastly/cli/pkg/commands/resourcelink" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" +) + +func TestCreateServiceResourceCommand(t *testing.T) { + scenarios := []struct { + args string + api mock.API + wantAPIInvoked bool + wantError string + wantOutput string + }{ + // Missing required arguments. + { + args: "create --service-id abc --resource-id 123", + wantError: "error parsing arguments: required flag --version not provided", + wantAPIInvoked: false, + }, + { + args: "create --service-id abc --version latest", + wantError: "error parsing arguments: required flag --resource-id not provided", + wantAPIInvoked: false, + }, + { + args: "create --resource-id abc --version latest", + wantError: "error reading service: no service ID found", + wantAPIInvoked: false, + }, + // Success. + { + args: "create --resource-id abc --service-id 123 --version 42", + api: mock.API{ + ListVersionsFn: func(_ *fastly.ListVersionsInput) ([]*fastly.Version, error) { + return []*fastly.Version{{Number: fastly.ToPointer(42)}}, nil + }, + CreateResourceFn: func(i *fastly.CreateResourceInput) (*fastly.Resource, error) { + if got, want := *i.ResourceID, "abc"; got != want { + return nil, fmt.Errorf("ResourceID: got %q, want %q", got, want) + } + if got, want := i.ServiceID, "123"; got != want { + return nil, fmt.Errorf("ServiceID: got %q, want %q", got, want) + } + if got, want := *i.Name, ""; got != want { + return nil, fmt.Errorf("Name: got %q, want %q", got, want) + } + now := time.Now() + return &fastly.Resource{ + LinkID: fastly.ToPointer("rand-id"), + Name: fastly.ToPointer("the-name"), + ResourceID: fastly.ToPointer("abc"), + ServiceID: fastly.ToPointer("123"), + ServiceVersion: fastly.ToPointer(42), + CreatedAt: &now, + UpdatedAt: &now, + }, nil + }, + }, + wantAPIInvoked: true, + wantOutput: `SUCCESS: Created service resource link "the-name" (rand-id) on service 123 version 42`, + }, + // Success with --name. + { + args: "create --resource-id abc --service-id 123 --version 42 --name testing", + api: mock.API{ + ListVersionsFn: func(_ *fastly.ListVersionsInput) ([]*fastly.Version, error) { + return []*fastly.Version{{Number: fastly.ToPointer(42)}}, nil + }, + CreateResourceFn: func(i *fastly.CreateResourceInput) (*fastly.Resource, error) { + if got, want := *i.ResourceID, "abc"; got != want { + return nil, fmt.Errorf("ResourceID: got %q, want %q", got, want) + } + if got, want := i.ServiceID, "123"; got != want { + return nil, fmt.Errorf("ServiceID: got %q, want %q", got, want) + } + if got, want := *i.Name, "testing"; got != want { + return nil, fmt.Errorf("Name: got %q, want %q", got, want) + } + now := time.Now() + return &fastly.Resource{ + LinkID: fastly.ToPointer("rand-id"), + Name: fastly.ToPointer("a-name"), + ResourceID: fastly.ToPointer("abc"), + ServiceID: fastly.ToPointer("123"), + ServiceVersion: fastly.ToPointer(42), + CreatedAt: &now, + UpdatedAt: &now, + }, nil + }, + }, + wantAPIInvoked: true, + wantOutput: `SUCCESS: Created service resource link "a-name" (rand-id) on service 123 version 42`, + }, + // Success with --autoclone. + { + args: "create --resource-id abc --service-id 123 --version=latest --autoclone", + api: mock.API{ + ListVersionsFn: func(_ *fastly.ListVersionsInput) ([]*fastly.Version, error) { + // Specified version is active, meaning a service clone will be attempted. + return []*fastly.Version{{Active: fastly.ToPointer(true), Number: fastly.ToPointer(42)}}, nil + }, + CloneVersionFn: func(_ *fastly.CloneVersionInput) (*fastly.Version, error) { + return &fastly.Version{Number: fastly.ToPointer(43)}, nil + }, + CreateResourceFn: func(i *fastly.CreateResourceInput) (*fastly.Resource, error) { + if got, want := *i.ResourceID, "abc"; got != want { + return nil, fmt.Errorf("ResourceID: got %q, want %q", got, want) + } + if got, want := i.ServiceID, "123"; got != want { + return nil, fmt.Errorf("ServiceID: got %q, want %q", got, want) + } + if got, want := *i.Name, ""; got != want { + return nil, fmt.Errorf("Name: got %q, want %q", got, want) + } + now := time.Now() + return &fastly.Resource{ + LinkID: fastly.ToPointer("rand-id"), + Name: fastly.ToPointer("cloned"), + ResourceID: fastly.ToPointer("abc"), + ServiceID: fastly.ToPointer("123"), + ServiceVersion: fastly.ToPointer(43), // Cloned version. + CreatedAt: &now, + UpdatedAt: &now, + }, nil + }, + }, + wantAPIInvoked: true, + wantOutput: `SUCCESS: Created service resource link "cloned" (rand-id) on service 123 version 43`, + }, + } + + for _, testcase := range scenarios { + testcase := testcase + t.Run(testcase.args, func(t *testing.T) { + var stdout bytes.Buffer + args := testutil.SplitArgs(resourcelink.RootName + " " + testcase.args) + opts := testutil.MockGlobalData(args, &stdout) + + f := testcase.api.CreateResourceFn + var apiInvoked bool + testcase.api.CreateResourceFn = func(i *fastly.CreateResourceInput) (*fastly.Resource, error) { + apiInvoked = true + return f(i) + } + + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(args, nil) + + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertString(t, testcase.wantOutput, strings.TrimSpace(stdout.String())) + if apiInvoked != testcase.wantAPIInvoked { + t.Fatalf("API CreateResource invoked = %v, want %v", apiInvoked, testcase.wantAPIInvoked) + } + }) + } +} + +func TestDeleteServiceResourceCommand(t *testing.T) { + scenarios := []struct { + args string + api mock.API + wantAPIInvoked bool + wantError string + wantOutput string + }{ + // Missing required arguments. + { + args: "delete --id LINK-ID --service-id abc", + wantError: "error parsing arguments: required flag --version not provided", + wantAPIInvoked: false, + }, + { + args: "delete --id LINK-ID --version 123", + wantError: "error reading service: no service ID found", + wantAPIInvoked: false, + }, + { + args: "delete --service-id abc --version 123", + wantError: "error parsing arguments: required flag --id not provided", + wantAPIInvoked: false, + }, + // Success. + { + args: "delete --service-id 123 --version 42 --id LINKID", + api: mock.API{ + ListVersionsFn: func(_ *fastly.ListVersionsInput) ([]*fastly.Version, error) { + return []*fastly.Version{{Number: fastly.ToPointer(42)}}, nil + }, + DeleteResourceFn: func(i *fastly.DeleteResourceInput) error { + if got, want := i.ResourceID, "LINKID"; got != want { + return fmt.Errorf("ID: got %q, want %q", got, want) + } + if got, want := i.ServiceID, "123"; got != want { + return fmt.Errorf("ServiceID: got %q, want %q", got, want) + } + if got, want := i.ServiceVersion, 42; got != want { + return fmt.Errorf("ServiceVersion: got %d, want %d", got, want) + } + return nil + }, + }, + wantAPIInvoked: true, + wantOutput: "SUCCESS: Deleted service resource link LINKID from service 123 version 42", + }, + // Success with --autoclone. + { + args: "delete --service-id 123 --version 42 --id LINKID --autoclone", + api: mock.API{ + ListVersionsFn: func(_ *fastly.ListVersionsInput) ([]*fastly.Version, error) { + // Specified version is active, meaning a service clone will be attempted. + return []*fastly.Version{{Active: fastly.ToPointer(true), Number: fastly.ToPointer(42)}}, nil + }, + CloneVersionFn: func(_ *fastly.CloneVersionInput) (*fastly.Version, error) { + return &fastly.Version{Number: fastly.ToPointer(43)}, nil + }, + DeleteResourceFn: func(i *fastly.DeleteResourceInput) error { + if got, want := i.ResourceID, "LINKID"; got != want { + return fmt.Errorf("ID: got %q, want %q", got, want) + } + if got, want := i.ServiceID, "123"; got != want { + return fmt.Errorf("ServiceID: got %q, want %q", got, want) + } + if got, want := i.ServiceVersion, 43; got != want { + return fmt.Errorf("ServiceVersion: got %d, want %d", got, want) + } + return nil + }, + }, + wantAPIInvoked: true, + wantOutput: "SUCCESS: Deleted service resource link LINKID from service 123 version 43", + }, + } + + for _, testcase := range scenarios { + testcase := testcase + t.Run(testcase.args, func(t *testing.T) { + var stdout bytes.Buffer + args := testutil.SplitArgs(resourcelink.RootName + " " + testcase.args) + opts := testutil.MockGlobalData(args, &stdout) + + f := testcase.api.DeleteResourceFn + var apiInvoked bool + testcase.api.DeleteResourceFn = func(i *fastly.DeleteResourceInput) error { + apiInvoked = true + return f(i) + } + + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(args, nil) + + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertString(t, testcase.wantOutput, strings.TrimSpace(stdout.String())) + if apiInvoked != testcase.wantAPIInvoked { + t.Fatalf("API DeleteResource invoked = %v, want %v", apiInvoked, testcase.wantAPIInvoked) + } + }) + } +} + +func TestDescribeServiceResourceCommand(t *testing.T) { + scenarios := []struct { + args string + api mock.API + wantAPIInvoked bool + wantError string + wantOutput string + }{ + // Missing required arguments. + { + args: "describe --id LINK-ID --service-id abc", + wantError: "error parsing arguments: required flag --version not provided", + wantAPIInvoked: false, + }, + { + args: "describe --id LINK-ID --version 123", + wantError: "error reading service: no service ID found", + wantAPIInvoked: false, + }, + { + args: "describe --service-id abc --version 123", + wantError: "error parsing arguments: required flag --id not provided", + wantAPIInvoked: false, + }, + // Success. + { + args: "describe --service-id 123 --version 42 --id LINKID", + api: mock.API{ + ListVersionsFn: func(_ *fastly.ListVersionsInput) ([]*fastly.Version, error) { + return []*fastly.Version{{Number: fastly.ToPointer(42)}}, nil + }, + GetResourceFn: func(i *fastly.GetResourceInput) (*fastly.Resource, error) { + if got, want := i.ResourceID, "LINKID"; got != want { + return nil, fmt.Errorf("ID: got %q, want %q", got, want) + } + if got, want := i.ServiceID, "123"; got != want { + return nil, fmt.Errorf("ServiceID: got %q, want %q", got, want) + } + if got, want := i.ServiceVersion, 42; got != want { + return nil, fmt.Errorf("ServiceVersion: got %d, want %d", got, want) + } + now := time.Unix(1697372322, 0) + return &fastly.Resource{ + LinkID: fastly.ToPointer("LINKID"), + ResourceID: fastly.ToPointer("abc"), + ResourceType: fastly.ToPointer("secret-store"), + Name: fastly.ToPointer("test-name"), + ServiceID: fastly.ToPointer("123"), + ServiceVersion: fastly.ToPointer(42), + CreatedAt: &now, + UpdatedAt: &now, + }, nil + }, + }, + wantAPIInvoked: true, + wantOutput: `Service ID: 123 +Service Version: 42 +ID: LINKID +Name: test-name +Service ID: 123 +Service Version: 42 +Resource ID: abc +Resource Type: secret-store +Created (UTC): 2023-10-15 12:18 +Last edited (UTC): 2023-10-15 12:18`, + }, + } + + for _, testcase := range scenarios { + testcase := testcase + t.Run(testcase.args, func(t *testing.T) { + var stdout bytes.Buffer + args := testutil.SplitArgs(resourcelink.RootName + " " + testcase.args) + opts := testutil.MockGlobalData(args, &stdout) + + f := testcase.api.GetResourceFn + var apiInvoked bool + testcase.api.GetResourceFn = func(i *fastly.GetResourceInput) (*fastly.Resource, error) { + apiInvoked = true + return f(i) + } + + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(args, nil) + + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertString(t, testcase.wantOutput, strings.TrimSpace(stdout.String())) + if apiInvoked != testcase.wantAPIInvoked { + t.Fatalf("API DescribeResource invoked = %v, want %v", apiInvoked, testcase.wantAPIInvoked) + } + }) + } +} + +func TestListServiceResourceCommand(t *testing.T) { + scenarios := []struct { + args string + api mock.API + wantAPIInvoked bool + wantError string + wantOutput string + }{ + // Missing required arguments. + { + args: "list --service-id abc", + wantError: "error parsing arguments: required flag --version not provided", + wantAPIInvoked: false, + }, + { + args: "list --version 123", + wantError: "error reading service: no service ID found", + wantAPIInvoked: false, + }, + // Success. + { + args: "list --service-id 123 --version 42", + api: mock.API{ + ListVersionsFn: func(_ *fastly.ListVersionsInput) ([]*fastly.Version, error) { + return []*fastly.Version{{Number: fastly.ToPointer(42)}}, nil + }, + ListResourcesFn: func(i *fastly.ListResourcesInput) ([]*fastly.Resource, error) { + if got, want := i.ServiceID, "123"; got != want { + return nil, fmt.Errorf("ServiceID: got %q, want %q", got, want) + } + if got, want := i.ServiceVersion, 42; got != want { + return nil, fmt.Errorf("ServiceVersion: got %d, want %d", got, want) + } + + now := time.Unix(1697372322, 0) + resources := make([]*fastly.Resource, 3) + for i := range resources { + resources[i] = &fastly.Resource{ + LinkID: fastly.ToPointer(fmt.Sprintf("LINKID-%02d", i)), + ResourceID: fastly.ToPointer("abc"), + ResourceType: fastly.ToPointer("secret-store"), + Name: fastly.ToPointer("test-name"), + ServiceID: fastly.ToPointer("123"), + ServiceVersion: fastly.ToPointer(42), + CreatedAt: &now, + UpdatedAt: &now, + } + } + return resources, nil + }, + }, + wantAPIInvoked: true, + wantOutput: `Service ID: 123 +Service Version: 42 +Resource Link 1/3 + ID: LINKID-00 + Name: test-name + Service ID: 123 + Service Version: 42 + Resource ID: abc + Resource Type: secret-store + Created (UTC): 2023-10-15 12:18 + Last edited (UTC): 2023-10-15 12:18 + +Resource Link 2/3 + ID: LINKID-01 + Name: test-name + Service ID: 123 + Service Version: 42 + Resource ID: abc + Resource Type: secret-store + Created (UTC): 2023-10-15 12:18 + Last edited (UTC): 2023-10-15 12:18 + +Resource Link 3/3 + ID: LINKID-02 + Name: test-name + Service ID: 123 + Service Version: 42 + Resource ID: abc + Resource Type: secret-store + Created (UTC): 2023-10-15 12:18 + Last edited (UTC): 2023-10-15 12:18`, + }, + } + + for _, testcase := range scenarios { + testcase := testcase + t.Run(testcase.args, func(t *testing.T) { + var stdout bytes.Buffer + args := testutil.SplitArgs(resourcelink.RootName + " " + testcase.args) + opts := testutil.MockGlobalData(args, &stdout) + + f := testcase.api.ListResourcesFn + var apiInvoked bool + testcase.api.ListResourcesFn = func(i *fastly.ListResourcesInput) ([]*fastly.Resource, error) { + apiInvoked = true + return f(i) + } + + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(args, nil) + + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertString(t, testcase.wantOutput, strings.ReplaceAll(strings.TrimSpace(stdout.String()), "\t", " ")) + if apiInvoked != testcase.wantAPIInvoked { + t.Fatalf("API ListResources invoked = %v, want %v", apiInvoked, testcase.wantAPIInvoked) + } + }) + } +} + +func TestUpdateServiceResourceCommand(t *testing.T) { + scenarios := []struct { + args string + api mock.API + wantAPIInvoked bool + wantError string + wantOutput string + }{ + // Missing required arguments. + { + args: "update --id LINK-ID --name new-name --service-id abc", + wantError: "error parsing arguments: required flag --version not provided", + wantAPIInvoked: false, + }, + { + args: "update --id LINK-ID --name new-name --version 123", + wantError: "error reading service: no service ID found", + wantAPIInvoked: false, + }, + { + args: "update --id LINK-ID --service-id abc --version 123", + wantError: "error parsing arguments: required flag --name not provided", + wantAPIInvoked: false, + }, + { + args: "update --name new-name --service-id abc --version 123", + wantError: "error parsing arguments: required flag --id not provided", + wantAPIInvoked: false, + }, + // Success. + { + args: "update --id LINK-ID --name new-name --service-id 123 --version 42", + api: mock.API{ + ListVersionsFn: func(_ *fastly.ListVersionsInput) ([]*fastly.Version, error) { + return []*fastly.Version{{Number: fastly.ToPointer(42)}}, nil + }, + UpdateResourceFn: func(i *fastly.UpdateResourceInput) (*fastly.Resource, error) { + if got, want := i.ResourceID, "LINK-ID"; got != want { + return nil, fmt.Errorf("ID: got %q, want %q", got, want) + } + if got, want := *i.Name, "new-name"; got != want { + return nil, fmt.Errorf("Name: got %q, want %q", got, want) + } + if got, want := i.ServiceID, "123"; got != want { + return nil, fmt.Errorf("ServiceID: got %q, want %q", got, want) + } + if got, want := i.ServiceVersion, 42; got != want { + return nil, fmt.Errorf("ServiceVersion: got %d, want %d", got, want) + } + + now := time.Now() + return &fastly.Resource{ + LinkID: fastly.ToPointer("LINK-ID"), + ResourceID: fastly.ToPointer("abc"), + ResourceType: fastly.ToPointer("secret-store"), + Name: fastly.ToPointer("new-name"), + ServiceID: fastly.ToPointer("123"), + ServiceVersion: fastly.ToPointer(42), + CreatedAt: &now, + UpdatedAt: &now, + }, nil + }, + }, + wantAPIInvoked: true, + wantOutput: "SUCCESS: Updated service resource link LINK-ID on service 123 version 42", + }, + // Success with --autoclone. + { + args: "update --id LINK-ID --name new-name --service-id 123 --version 42 --autoclone", + api: mock.API{ + ListVersionsFn: func(_ *fastly.ListVersionsInput) ([]*fastly.Version, error) { + // Specified version is active, meaning a service clone will be attempted. + return []*fastly.Version{{Active: fastly.ToPointer(true), Number: fastly.ToPointer(42)}}, nil + }, + CloneVersionFn: func(_ *fastly.CloneVersionInput) (*fastly.Version, error) { + return &fastly.Version{Number: fastly.ToPointer(43)}, nil + }, + UpdateResourceFn: func(i *fastly.UpdateResourceInput) (*fastly.Resource, error) { + if got, want := i.ResourceID, "LINK-ID"; got != want { + return nil, fmt.Errorf("ID: got %q, want %q", got, want) + } + if got, want := *i.Name, "new-name"; got != want { + return nil, fmt.Errorf("Name: got %q, want %q", got, want) + } + if got, want := i.ServiceID, "123"; got != want { + return nil, fmt.Errorf("ServiceID: got %q, want %q", got, want) + } + if got, want := i.ServiceVersion, 43; got != want { + return nil, fmt.Errorf("ServiceVersion: got %d, want %d", got, want) + } + + now := time.Now() + return &fastly.Resource{ + LinkID: fastly.ToPointer("LINK-ID"), + ResourceID: fastly.ToPointer("abc"), + ResourceType: fastly.ToPointer("secret-store"), + Name: fastly.ToPointer("new-name"), + ServiceID: fastly.ToPointer("123"), + ServiceVersion: fastly.ToPointer(43), + CreatedAt: &now, + UpdatedAt: &now, + }, nil + }, + }, + wantAPIInvoked: true, + wantOutput: "SUCCESS: Updated service resource link LINK-ID on service 123 version 43", + }, + } + + for _, testcase := range scenarios { + testcase := testcase + t.Run(testcase.args, func(t *testing.T) { + var stdout bytes.Buffer + args := testutil.SplitArgs(resourcelink.RootName + " " + testcase.args) + opts := testutil.MockGlobalData(args, &stdout) + + f := testcase.api.UpdateResourceFn + var apiInvoked bool + testcase.api.UpdateResourceFn = func(i *fastly.UpdateResourceInput) (*fastly.Resource, error) { + apiInvoked = true + return f(i) + } + + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(args, nil) + + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertString(t, testcase.wantOutput, strings.TrimSpace(stdout.String())) + if apiInvoked != testcase.wantAPIInvoked { + t.Fatalf("API UpdateResource invoked = %v, want %v", apiInvoked, testcase.wantAPIInvoked) + } + }) + } +} diff --git a/pkg/commands/resourcelink/root.go b/pkg/commands/resourcelink/root.go new file mode 100644 index 000000000..7f984edc5 --- /dev/null +++ b/pkg/commands/resourcelink/root.go @@ -0,0 +1,42 @@ +package resourcelink + +import ( + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" +) + +// RootName is the name of this package's sub-command in the CLI, +// e.g. "fastly resource-link". +const RootName = "resource-link" + +// Common flag descriptions. +const ( + flagNameDescription = "Resource link name (alias). Defaults to resource's name" + flagIDDescription = "Resource link ID" + flagResourceIDDescription = "Resource ID" +) + +// RootCommand is the parent command for all subcommands in this package. +// It should be installed under the primary root command. +type RootCommand struct { + argparser.Base + // no flags +} + +// CommandName is the string to be used to invoke this command. +const CommandName = "resource-link" + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent argparser.Registerer, g *global.Data) *RootCommand { + var c RootCommand + c.Globals = g + c.CmdClause = parent.Command(CommandName, "Manipulate Fastly service resource links") + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { + panic("unreachable") +} diff --git a/pkg/commands/resourcelink/update.go b/pkg/commands/resourcelink/update.go new file mode 100644 index 000000000..c18b0c1d0 --- /dev/null +++ b/pkg/commands/resourcelink/update.go @@ -0,0 +1,134 @@ +package resourcelink + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// UpdateCommand calls the Fastly API to update a dictionary. +type UpdateCommand struct { + argparser.Base + argparser.JSONOutput + + autoClone argparser.OptionalAutoClone + input fastly.UpdateResourceInput + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion +} + +// NewUpdateCommand returns a usable command registered under the parent. +func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { + c := UpdateCommand{ + Base: argparser.Base{ + Globals: g, + }, + input: fastly.UpdateResourceInput{ + // Kingpin requires the following to be initialized. + Name: new(string), + }, + } + c.CmdClause = parent.Command("update", "Update a resource link for a Fastly service version") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: "id", + Description: flagIDDescription, + Dst: &c.input.ResourceID, + Required: true, + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: "name", + Short: 'n', + Description: flagNameDescription, + Dst: c.input.Name, + Required: true, + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // At least one of the following is required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Short: 's', + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceName, + Action: c.serviceName.Set, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + + // Optional. + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.autoClone.Set, + Dst: &c.autoClone.Value, + }) + c.RegisterFlagBool(c.JSONFlag()) // --json + + return &c +} + +// Exec invokes the application logic for the command. +func (c *UpdateCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.autoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": c.Globals.Manifest.Flag.ServiceID, + "Service Version": fsterr.ServiceVersion(serviceVersion), + }) + return err + } + + c.input.ServiceID = serviceID + c.input.ServiceVersion = fastly.ToValue(serviceVersion.Number) + + o, err := c.Globals.APIClient.UpdateResource(&c.input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "ID": c.input.ResourceID, + "Service ID": c.input.ServiceID, + "Service Version": c.input.ServiceVersion, + }) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + text.Success(out, + "Updated service resource link %s on service %s version %d", + fastly.ToValue(o.LinkID), + fastly.ToValue(o.ServiceID), + fastly.ToValue(o.ServiceVersion), + ) + return nil +} diff --git a/pkg/commands/secretstore/create.go b/pkg/commands/secretstore/create.go new file mode 100644 index 000000000..2656d8a43 --- /dev/null +++ b/pkg/commands/secretstore/create.go @@ -0,0 +1,60 @@ +package secretstore + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// NewCreateCommand returns a usable command registered under the parent. +func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { + c := CreateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + + c.CmdClause = parent.Command("create", "Create a new secret store") + + // Required. + c.RegisterFlag(storeNameFlag(&c.Input.Name)) // --name + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + + return &c +} + +// CreateCommand calls the Fastly API to create an appropriate resource. +type CreateCommand struct { + argparser.Base + argparser.JSONOutput + + Input fastly.CreateSecretStoreInput +} + +// Exec invokes the application logic for the command. +func (c *CreateCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + o, err := c.Globals.APIClient.CreateSecretStore(&c.Input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + text.Success(out, "Created Secret Store '%s' (%s)", o.Name, o.StoreID) + + return nil +} diff --git a/pkg/commands/secretstore/delete.go b/pkg/commands/secretstore/delete.go new file mode 100644 index 000000000..c831d7c86 --- /dev/null +++ b/pkg/commands/secretstore/delete.go @@ -0,0 +1,67 @@ +package secretstore + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// NewDeleteCommand returns a usable command registered under the parent. +func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { + c := DeleteCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + + c.CmdClause = parent.Command("delete", "Delete a secret store") + + // Required. + c.RegisterFlag(argparser.StoreIDFlag(&c.Input.StoreID)) // --store-id + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + + return &c +} + +// DeleteCommand calls the Fastly API to delete an appropriate resource. +type DeleteCommand struct { + argparser.Base + argparser.JSONOutput + + Input fastly.DeleteSecretStoreInput +} + +// Exec invokes the application logic for the command. +func (c *DeleteCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + err := c.Globals.APIClient.DeleteSecretStore(&c.Input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if c.JSONOutput.Enabled { + o := struct { + ID string `json:"id"` + Deleted bool `json:"deleted"` + }{ + c.Input.StoreID, + true, + } + _, err := c.WriteJSON(out, o) + return err + } + + text.Success(out, "Deleted Secret Store '%s'", c.Input.StoreID) + return nil +} diff --git a/pkg/commands/secretstore/describe.go b/pkg/commands/secretstore/describe.go new file mode 100644 index 000000000..11cf49382 --- /dev/null +++ b/pkg/commands/secretstore/describe.go @@ -0,0 +1,60 @@ +package secretstore + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// NewDescribeCommand returns a usable command registered under the parent. +func NewDescribeCommand(parent argparser.Registerer, g *global.Data) *DescribeCommand { + c := DescribeCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + + c.CmdClause = parent.Command("describe", "Retrieve a single secret store").Alias("get") + + // Required. + c.RegisterFlag(argparser.StoreIDFlag(&c.Input.StoreID)) // --store-id + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + + return &c +} + +// DescribeCommand calls the Fastly API to describe an appropriate resource. +type DescribeCommand struct { + argparser.Base + argparser.JSONOutput + + Input fastly.GetSecretStoreInput +} + +// Exec invokes the application logic for the command. +func (c *DescribeCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + o, err := c.Globals.APIClient.GetSecretStore(&c.Input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + text.PrintSecretStore(out, "", o) + + return nil +} diff --git a/pkg/commands/secretstore/doc.go b/pkg/commands/secretstore/doc.go new file mode 100644 index 000000000..8e989f81e --- /dev/null +++ b/pkg/commands/secretstore/doc.go @@ -0,0 +1,5 @@ +// Package secretstore contains commands to inspect and manipulate Fastly edge +// secret stores. +// +// https://www.fastly.com/documentation/reference/api/services/resources/secret-store +package secretstore diff --git a/pkg/commands/secretstore/flags.go b/pkg/commands/secretstore/flags.go new file mode 100644 index 000000000..15f0d0f76 --- /dev/null +++ b/pkg/commands/secretstore/flags.go @@ -0,0 +1,15 @@ +package secretstore + +import ( + "github.com/fastly/cli/pkg/argparser" +) + +func storeNameFlag(dst *string) argparser.StringFlagOpts { + return argparser.StringFlagOpts{ + Name: "name", + Short: 'n', + Description: "Store name", + Dst: dst, + Required: true, + } +} diff --git a/pkg/commands/secretstore/helper_test.go b/pkg/commands/secretstore/helper_test.go new file mode 100644 index 000000000..fe70cf4bb --- /dev/null +++ b/pkg/commands/secretstore/helper_test.go @@ -0,0 +1,21 @@ +package secretstore_test + +import ( + "bytes" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/text" +) + +func fmtStore(s *fastly.SecretStore) string { + var b bytes.Buffer + text.PrintSecretStore(&b, "", s) + return b.String() +} + +func fmtStores(s []fastly.SecretStore) string { + var b bytes.Buffer + text.PrintSecretStoresTbl(&b, s) + return b.String() +} diff --git a/pkg/commands/secretstore/list.go b/pkg/commands/secretstore/list.go new file mode 100644 index 000000000..5a3706676 --- /dev/null +++ b/pkg/commands/secretstore/list.go @@ -0,0 +1,98 @@ +package secretstore + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// NewListCommand returns a usable command registered under the parent. +func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { + c := ListCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + + c.CmdClause = parent.Command("list", "List secret stores") + + // Optional. + c.RegisterFlag(argparser.CursorFlag(&c.Input.Cursor)) // --cursor + c.RegisterFlagBool(c.JSONFlag()) // --json + c.RegisterFlagInt(argparser.LimitFlag(&c.Input.Limit)) // --limit + + return &c +} + +// ListCommand calls the Fastly API to list appropriate resources. +type ListCommand struct { + argparser.Base + argparser.JSONOutput + + // NOTE: API returns 10 items even when --limit is set to smaller. + Input fastly.ListSecretStoresInput +} + +// Exec invokes the application logic for the command. +func (c *ListCommand) Exec(in io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + var data []fastly.SecretStore + + for { + o, err := c.Globals.APIClient.ListSecretStores(&c.Input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if o != nil { + data = append(data, o.Data...) + + if c.JSONOutput.Enabled || c.Globals.Flags.NonInteractive || c.Globals.Flags.AutoYes { + if o.Meta.NextCursor != "" { + c.Input.Cursor = o.Meta.NextCursor + continue + } + break + } + + text.PrintSecretStoresTbl(out, o.Data) + + if o.Meta.NextCursor != "" { + text.Break(out) + printNext, err := text.AskYesNo(out, "Print next page [y/N]: ", in) + if err != nil { + return err + } + if printNext { + text.Break(out) + c.Input.Cursor = o.Meta.NextCursor + continue + } + } + } + + break + } + + ok, err := c.WriteJSON(out, data) + if err != nil { + return err + } + + // Only print output here if we've not already printed JSON. + // And only if we're non interactive. + // Otherwise interactive mode would have displayed each page of data. + if !ok && (c.Globals.Flags.NonInteractive || c.Globals.Flags.AutoYes) { + text.PrintSecretStoresTbl(out, data) + } + return nil +} diff --git a/pkg/commands/secretstore/root.go b/pkg/commands/secretstore/root.go new file mode 100644 index 000000000..099c73730 --- /dev/null +++ b/pkg/commands/secretstore/root.go @@ -0,0 +1,39 @@ +package secretstore + +import ( + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" +) + +// RootNameStore is the base command name for secret store operations. +const RootNameStore = "secret-store" + +// CommandName is the string to be used to invoke this command. +const CommandName = "secret-store" + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent argparser.Registerer, g *global.Data) *RootCommand { + c := RootCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + + c.CmdClause = parent.Command(CommandName, "Manipulate Fastly Secret Stores") + + return &c +} + +// RootCommand is the parent command for all 'store' subcommands. +// It should be installed under the primary root command. +type RootCommand struct { + argparser.Base + // no flags +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { + panic("unreachable") +} diff --git a/pkg/commands/secretstore/secretstore_test.go b/pkg/commands/secretstore/secretstore_test.go new file mode 100644 index 000000000..30aa0f2d4 --- /dev/null +++ b/pkg/commands/secretstore/secretstore_test.go @@ -0,0 +1,372 @@ +package secretstore_test + +import ( + "bytes" + "errors" + "fmt" + "io" + "testing" + "time" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/app" + "github.com/fastly/cli/pkg/commands/secretstore" + fstfmt "github.com/fastly/cli/pkg/fmt" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" +) + +func TestCreateStoreCommand(t *testing.T) { + const ( + storeName = "test123" + storeID = "store-id-123" + ) + now := time.Now() + + scenarios := []struct { + args string + api mock.API + wantAPIInvoked bool + wantError string + wantOutput string + }{ + { + args: "create", + wantError: "error parsing arguments: required flag --name not provided", + }, + { + args: fmt.Sprintf("create --name %s", storeName), + api: mock.API{ + CreateSecretStoreFn: func(_ *fastly.CreateSecretStoreInput) (*fastly.SecretStore, error) { + return nil, errors.New("invalid request") + }, + }, + wantAPIInvoked: true, + wantError: "invalid request", + }, + { + args: fmt.Sprintf("create --name %s", storeName), + api: mock.API{ + CreateSecretStoreFn: func(i *fastly.CreateSecretStoreInput) (*fastly.SecretStore, error) { + return &fastly.SecretStore{ + StoreID: storeID, + Name: i.Name, + }, nil + }, + }, + wantAPIInvoked: true, + wantOutput: fstfmt.Success("Created Secret Store '%s' (%s)", storeName, storeID), + }, + { + args: fmt.Sprintf("create --name %s --json", storeName), + api: mock.API{ + CreateSecretStoreFn: func(i *fastly.CreateSecretStoreInput) (*fastly.SecretStore, error) { + return &fastly.SecretStore{ + StoreID: storeID, + Name: i.Name, + CreatedAt: now, + }, nil + }, + }, + wantAPIInvoked: true, + wantOutput: fstfmt.JSON(`{"created_at": %q, "name": %q, "id": %q}`, now.Format(time.RFC3339Nano), storeName, storeID), + }, + } + + for _, testcase := range scenarios { + testcase := testcase + t.Run(testcase.args, func(t *testing.T) { + var stdout bytes.Buffer + args := testutil.SplitArgs(secretstore.RootNameStore + " " + testcase.args) + opts := testutil.MockGlobalData(args, &stdout) + + f := testcase.api.CreateSecretStoreFn + var apiInvoked bool + testcase.api.CreateSecretStoreFn = func(i *fastly.CreateSecretStoreInput) (*fastly.SecretStore, error) { + apiInvoked = true + return f(i) + } + + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(args, nil) + + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertString(t, testcase.wantOutput, stdout.String()) + if apiInvoked != testcase.wantAPIInvoked { + t.Fatalf("API CreateSecretStore invoked = %v, want %v", apiInvoked, testcase.wantAPIInvoked) + } + }) + } +} + +func TestDeleteStoreCommand(t *testing.T) { + const storeID = "test123" + errStoreNotFound := errors.New("store not found") + + scenarios := []struct { + args string + api mock.API + wantAPIInvoked bool + wantError string + wantOutput string + }{ + { + args: "delete", + wantError: "error parsing arguments: required flag --store-id not provided", + }, + { + args: "delete --store-id DOES-NOT-EXIST", + api: mock.API{ + DeleteSecretStoreFn: func(i *fastly.DeleteSecretStoreInput) error { + if i.StoreID != storeID { + return errStoreNotFound + } + return nil + }, + }, + wantAPIInvoked: true, + wantError: errStoreNotFound.Error(), + }, + { + args: fmt.Sprintf("delete --store-id %s", storeID), + api: mock.API{ + DeleteSecretStoreFn: func(i *fastly.DeleteSecretStoreInput) error { + if i.StoreID != storeID { + return errStoreNotFound + } + return nil + }, + }, + wantAPIInvoked: true, + wantOutput: fstfmt.Success("Deleted Secret Store '%s'\n", storeID), + }, + { + args: fmt.Sprintf("delete --store-id %s --json", storeID), + api: mock.API{ + DeleteSecretStoreFn: func(i *fastly.DeleteSecretStoreInput) error { + if i.StoreID != storeID { + return errStoreNotFound + } + return nil + }, + }, + wantAPIInvoked: true, + wantOutput: fstfmt.JSON(`{"id": %q, "deleted": true}`, storeID), + }, + } + + for _, testcase := range scenarios { + testcase := testcase + t.Run(testcase.args, func(t *testing.T) { + var stdout bytes.Buffer + args := testutil.SplitArgs(secretstore.RootNameStore + " " + testcase.args) + opts := testutil.MockGlobalData(args, &stdout) + + f := testcase.api.DeleteSecretStoreFn + var apiInvoked bool + testcase.api.DeleteSecretStoreFn = func(i *fastly.DeleteSecretStoreInput) error { + apiInvoked = true + return f(i) + } + + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(args, nil) + + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertString(t, testcase.wantOutput, stdout.String()) + if apiInvoked != testcase.wantAPIInvoked { + t.Fatalf("API DeleteSecretStore invoked = %v, want %v", apiInvoked, testcase.wantAPIInvoked) + } + }) + } +} + +func TestDescribeStoreCommand(t *testing.T) { + const ( + storeName = "test123" + storeID = "store-id-123" + ) + + scenarios := []struct { + args string + api mock.API + wantAPIInvoked bool + wantError string + wantOutput string + }{ + { + args: "get", + wantError: "error parsing arguments: required flag --store-id not provided", + }, + { + args: fmt.Sprintf("get --store-id %s", storeID), + api: mock.API{ + GetSecretStoreFn: func(_ *fastly.GetSecretStoreInput) (*fastly.SecretStore, error) { + return nil, errors.New("invalid request") + }, + }, + wantAPIInvoked: true, + wantError: "invalid request", + }, + { + args: fmt.Sprintf("get --store-id %s", storeID), + api: mock.API{ + GetSecretStoreFn: func(i *fastly.GetSecretStoreInput) (*fastly.SecretStore, error) { + return &fastly.SecretStore{ + StoreID: i.StoreID, + Name: storeName, + }, nil + }, + }, + wantAPIInvoked: true, + wantOutput: fmtStore(&fastly.SecretStore{ + StoreID: storeID, + Name: storeName, + }), + }, + { + args: fmt.Sprintf("get --store-id %s --json", storeID), + api: mock.API{ + GetSecretStoreFn: func(i *fastly.GetSecretStoreInput) (*fastly.SecretStore, error) { + return &fastly.SecretStore{ + StoreID: i.StoreID, + Name: storeName, + }, nil + }, + }, + wantAPIInvoked: true, + wantOutput: fstfmt.EncodeJSON(&fastly.SecretStore{ + StoreID: storeID, + Name: storeName, + }), + }, + } + + for _, testcase := range scenarios { + testcase := testcase + t.Run(testcase.args, func(t *testing.T) { + var stdout bytes.Buffer + args := testutil.SplitArgs(secretstore.RootNameStore + " " + testcase.args) + opts := testutil.MockGlobalData(args, &stdout) + + f := testcase.api.GetSecretStoreFn + var apiInvoked bool + testcase.api.GetSecretStoreFn = func(i *fastly.GetSecretStoreInput) (*fastly.SecretStore, error) { + apiInvoked = true + return f(i) + } + + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(args, nil) + + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertString(t, testcase.wantOutput, stdout.String()) + if apiInvoked != testcase.wantAPIInvoked { + t.Fatalf("API GetSecretStore invoked = %v, want %v", apiInvoked, testcase.wantAPIInvoked) + } + }) + } +} + +func TestListStoresCommand(t *testing.T) { + const ( + storeName = "test123" + storeID = "store-id-123" + ) + + stores := &fastly.SecretStores{ + Meta: fastly.SecretStoreMeta{ + Limit: 123, + }, + Data: []fastly.SecretStore{ + {StoreID: storeID, Name: storeName}, + }, + } + + scenarios := []struct { + args string + api mock.API + wantAPIInvoked bool + wantError string + wantOutput string + }{ + { + args: "list", + api: mock.API{ + ListSecretStoresFn: func(_ *fastly.ListSecretStoresInput) (*fastly.SecretStores, error) { + return nil, nil + }, + }, + wantAPIInvoked: true, + }, + { + args: "list", + api: mock.API{ + ListSecretStoresFn: func(_ *fastly.ListSecretStoresInput) (*fastly.SecretStores, error) { + return nil, errors.New("unknown error") + }, + }, + wantAPIInvoked: true, + wantError: "unknown error", + }, + { + args: "list", + api: mock.API{ + ListSecretStoresFn: func(_ *fastly.ListSecretStoresInput) (*fastly.SecretStores, error) { + return stores, nil + }, + }, + wantAPIInvoked: true, + wantOutput: fmtStores(stores.Data), + }, + { + args: "list --json", + api: mock.API{ + ListSecretStoresFn: func(_ *fastly.ListSecretStoresInput) (*fastly.SecretStores, error) { + return stores, nil + }, + }, + wantAPIInvoked: true, + wantOutput: fstfmt.EncodeJSON([]fastly.SecretStore{stores.Data[0]}), + }, + } + + for _, testcase := range scenarios { + testcase := testcase + t.Run(testcase.args, func(t *testing.T) { + var stdout bytes.Buffer + args := testutil.SplitArgs(secretstore.RootNameStore + " " + testcase.args) + opts := testutil.MockGlobalData(args, &stdout) + + f := testcase.api.ListSecretStoresFn + var apiInvoked bool + testcase.api.ListSecretStoresFn = func(i *fastly.ListSecretStoresInput) (*fastly.SecretStores, error) { + apiInvoked = true + return f(i) + } + + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(args, nil) + + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertStringContains(t, stdout.String(), testcase.wantOutput) + if apiInvoked != testcase.wantAPIInvoked { + t.Fatalf("API ListSecretStores invoked = %v, want %v", apiInvoked, testcase.wantAPIInvoked) + } + }) + } +} diff --git a/pkg/commands/secretstoreentry/create.go b/pkg/commands/secretstoreentry/create.go new file mode 100644 index 000000000..9a7516a4f --- /dev/null +++ b/pkg/commands/secretstoreentry/create.go @@ -0,0 +1,202 @@ +package secretstoreentry + +import ( + "bytes" + "encoding/base64" + "encoding/hex" + "fmt" + "io" + "net/http" + "os" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +const ( + // Maximum secret length, as defined at https://www.fastly.com/documentation/reference/api/services/resources/secret-store-secret + maxSecretKiB = 64 + maxSecretLen = maxSecretKiB * 1024 +) + +// The signing key is a public key that is used to sign client keys. +// It's meant to be a long-lived key and infrequently (if ever) rotated. +// Hardcoding it in the CLI gives us the benefit of distributing it via +// a different channel from the client keys it's signing. +// +// When we do rotate it, we will need to update this value and release a +// new version of the CLI. However, users can also override this with +// the FASTLY_USE_API_SIGNING_KEY environment variable. +var signingKey = mustDecode("CrO/A92vkxEZjtTW7D/Sr+1EMf/q9BahC0sfLkWa+0k=") + +func mustDecode(s string) []byte { + b, err := base64.StdEncoding.DecodeString(s) + if err != nil { + panic(err) + } + return b +} + +// NewCreateCommand returns a usable command registered under the parent. +func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { + c := CreateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + + c.CmdClause = parent.Command("create", "Create a new secret within specified store") + + // Required. + c.RegisterFlag(secretNameFlag(&c.Input.Name)) // --name + c.RegisterFlag(argparser.StoreIDFlag(&c.Input.StoreID)) // --store-id + + // Optional. + c.RegisterFlag(secretFileFlag(&c.secretFile)) // --file + c.RegisterFlagBool(c.JSONFlag()) // --json + c.RegisterFlagBool(argparser.BoolFlagOpts{ + Name: "recreate", + Description: "Recreate secret by name (errors if secret doesn't already exist)", + Dst: &c.recreate, + Required: false, + }) + c.RegisterFlagBool(argparser.BoolFlagOpts{ + Name: "recreate-allow", + Description: "Create or recreate secret by name", + Dst: &c.recreateAllow, + Required: false, + }) + c.RegisterFlagBool(secretStdinFlag(&c.secretSTDIN)) // --stdin + + return &c +} + +// CreateCommand calls the Fastly API to create an appropriate resource. +type CreateCommand struct { + argparser.Base + argparser.JSONOutput + + Input fastly.CreateSecretInput + recreate bool + recreateAllow bool + secretFile string + secretSTDIN bool +} + +var errMultipleSecretValue = fsterr.RemediationError{ + Inner: fmt.Errorf("invalid flag combination, --file and --stdin"), + Remediation: "Use one of --file or --stdin flag", +} + +var errMaxSecretLength = fsterr.RemediationError{ + Inner: fmt.Errorf("max secret size exceeded"), + Remediation: fmt.Sprintf("Maximum secret size is %dKiB", maxSecretKiB), +} + +// Exec invokes the application logic for the command. +func (c *CreateCommand) Exec(in io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + if c.secretFile != "" && c.secretSTDIN { + return errMultipleSecretValue + } + + switch { + case c.recreate && c.recreateAllow: + return fsterr.RemediationError{ + Inner: fmt.Errorf("invalid flag combination, --recreate and --recreate-allow"), + Remediation: "Use either --recreate or --recreate-allow, not both.", + } + case c.recreate: + c.Input.Method = http.MethodPatch + case c.recreateAllow: + c.Input.Method = http.MethodPut + } + + // Read secret's value: either from STDIN, a file, or prompt. + switch { + case c.secretSTDIN: + // Determine if 'in' has data available. + if in == nil || text.IsTTY(in) { + return fsterr.ErrNoSTDINData + } + var buf bytes.Buffer + if _, err := buf.ReadFrom(in); err != nil { + return err + } + c.Input.Secret = buf.Bytes() + + case c.secretFile != "": + var err error + // nosemgrep: trailofbits.go.questionable-assignment.questionable-assignment + if c.Input.Secret, err = os.ReadFile(c.secretFile); err != nil { + return err + } + + default: + secret, err := text.InputSecure(out, "Secret: ", in) + if err != nil { + return err + } + c.Input.Secret = []byte(secret) + } + + if len(c.Input.Secret) > maxSecretLen { + return errMaxSecretLength + } + + ck, err := c.Globals.APIClient.CreateClientKey() + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + sk, err := c.Globals.APIClient.GetSigningKey() + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if !bytes.Equal(sk, signingKey) && os.Getenv("FASTLY_USE_API_SIGNING_KEY") == "" { + err := fmt.Errorf("API signing key does not match expected value") + c.Globals.ErrLog.Add(err) + return err + } + + if !ck.VerifySignature(sk) { + err := fmt.Errorf("unable to validate signature of client key") + c.Globals.ErrLog.Add(err) + return err + } + + wrapped, err := ck.Encrypt(c.Input.Secret) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + c.Input.Secret = wrapped + c.Input.ClientKey = ck.PublicKey + + o, err := c.Globals.APIClient.CreateSecret(&c.Input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + action := "Created" + if o.Recreated { + action = "Recreated" + } + text.Success(out, "%s secret '%s' in Secret Store '%s' (digest: %s)", action, o.Name, c.Input.StoreID, hex.EncodeToString(o.Digest)) + return nil +} diff --git a/pkg/commands/secretstoreentry/delete.go b/pkg/commands/secretstoreentry/delete.go new file mode 100644 index 000000000..5aa039e39 --- /dev/null +++ b/pkg/commands/secretstoreentry/delete.go @@ -0,0 +1,70 @@ +package secretstoreentry + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// NewDeleteCommand returns a usable command registered under the parent. +func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { + c := DeleteCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + + c.CmdClause = parent.Command("delete", "Delete a secret") + + // Required. + c.RegisterFlag(secretNameFlag(&c.Input.Name)) // --name + c.RegisterFlag(argparser.StoreIDFlag(&c.Input.StoreID)) // --store-id + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + + return &c +} + +// DeleteCommand calls the Fastly API to delete an appropriate resource. +type DeleteCommand struct { + argparser.Base + argparser.JSONOutput + + Input fastly.DeleteSecretInput +} + +// Exec invokes the application logic for the command. +func (c *DeleteCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + err := c.Globals.APIClient.DeleteSecret(&c.Input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if c.JSONOutput.Enabled { + o := struct { + Name string `json:"name"` + ID string `json:"store_id"` + Deleted bool `json:"deleted"` + }{ + c.Input.Name, + c.Input.StoreID, + true, + } + _, err := c.WriteJSON(out, o) + return err + } + + text.Success(out, "Deleted secret '%s' from Secret Store '%s'", c.Input.Name, c.Input.StoreID) + return nil +} diff --git a/pkg/commands/secretstoreentry/describe.go b/pkg/commands/secretstoreentry/describe.go new file mode 100644 index 000000000..d6c478168 --- /dev/null +++ b/pkg/commands/secretstoreentry/describe.go @@ -0,0 +1,61 @@ +package secretstoreentry + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// NewDescribeCommand returns a usable command registered under the parent. +func NewDescribeCommand(parent argparser.Registerer, g *global.Data) *DescribeCommand { + c := DescribeCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + + c.CmdClause = parent.Command("describe", "Retrieve a single secret").Alias("get") + + // Required. + c.RegisterFlag(secretNameFlag(&c.Input.Name)) // --name + c.RegisterFlag(argparser.StoreIDFlag(&c.Input.StoreID)) // --store-id + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + + return &c +} + +// DescribeCommand calls the Fastly API to describe an appropriate resource. +type DescribeCommand struct { + argparser.Base + argparser.JSONOutput + + Input fastly.GetSecretInput +} + +// Exec invokes the application logic for the command. +func (c *DescribeCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + o, err := c.Globals.APIClient.GetSecret(&c.Input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + text.PrintSecret(out, "", o) + + return nil +} diff --git a/pkg/commands/secretstoreentry/doc.go b/pkg/commands/secretstoreentry/doc.go new file mode 100644 index 000000000..019c955b9 --- /dev/null +++ b/pkg/commands/secretstoreentry/doc.go @@ -0,0 +1,5 @@ +// Package secretstoreentry contains commands to inspect and manipulate Fastly +// edge secret store data. +// +// https://www.fastly.com/documentation/reference/api/services/resources/secret-store +package secretstoreentry diff --git a/pkg/commands/secretstoreentry/flags.go b/pkg/commands/secretstoreentry/flags.go new file mode 100644 index 000000000..a4da6410f --- /dev/null +++ b/pkg/commands/secretstoreentry/flags.go @@ -0,0 +1,34 @@ +package secretstoreentry + +import ( + "github.com/fastly/cli/pkg/argparser" +) + +func secretNameFlag(dst *string) argparser.StringFlagOpts { + return argparser.StringFlagOpts{ + Name: "name", + Short: 'n', + Description: "Secret name", + Dst: dst, + Required: true, + } +} + +func secretFileFlag(dst *string) argparser.StringFlagOpts { + return argparser.StringFlagOpts{ + Name: "file", + Short: 'f', + Description: "Read secret value from file instead of prompt", + Dst: dst, + Required: false, + } +} + +func secretStdinFlag(dst *bool) argparser.BoolFlagOpts { + return argparser.BoolFlagOpts{ + Name: "stdin", + Description: "Read secret value from STDIN instead of prompt", + Dst: dst, + Required: false, + } +} diff --git a/pkg/commands/secretstoreentry/helper_test.go b/pkg/commands/secretstoreentry/helper_test.go new file mode 100644 index 000000000..ff689394b --- /dev/null +++ b/pkg/commands/secretstoreentry/helper_test.go @@ -0,0 +1,20 @@ +package secretstoreentry_test + +import ( + "bytes" + + "github.com/fastly/cli/pkg/text" + "github.com/fastly/go-fastly/v10/fastly" +) + +func fmtSecret(s *fastly.Secret) string { + var b bytes.Buffer + text.PrintSecret(&b, "", s) + return b.String() +} + +func fmtSecrets(s *fastly.Secrets) string { + var b bytes.Buffer + text.PrintSecretsTbl(&b, s) + return b.String() +} diff --git a/pkg/commands/secretstoreentry/list.go b/pkg/commands/secretstoreentry/list.go new file mode 100644 index 000000000..ed29ad4e3 --- /dev/null +++ b/pkg/commands/secretstoreentry/list.go @@ -0,0 +1,79 @@ +package secretstoreentry + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// NewListCommand returns a usable command registered under the parent. +func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { + c := ListCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + + c.CmdClause = parent.Command("list", "List secrets within a specified store") + + // Required. + c.RegisterFlag(argparser.StoreIDFlag(&c.Input.StoreID)) // --store-id + + // Optional. + c.RegisterFlag(argparser.CursorFlag(&c.Input.Cursor)) // --cursor + c.RegisterFlagBool(c.JSONFlag()) // --json + c.RegisterFlagInt(argparser.LimitFlag(&c.Input.Limit)) // --limit + + return &c +} + +// ListCommand calls the Fastly API to list appropriate resources. +type ListCommand struct { + argparser.Base + argparser.JSONOutput + + Input fastly.ListSecretsInput +} + +// Exec invokes the application logic for the command. +func (c *ListCommand) Exec(in io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + for { + o, err := c.Globals.APIClient.ListSecrets(&c.Input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + // No pagination prompt w/ JSON output. + return err + } + + text.PrintSecretsTbl(out, o) + + if o != nil && o.Meta.NextCursor != "" { + // Check if 'out' is interactive before prompting. + if !c.Globals.Flags.NonInteractive && !c.Globals.Flags.AutoYes && text.IsTTY(out) { + printNext, err := text.AskYesNo(out, "Print next page [y/N]: ", in) + if err != nil { + return err + } + if printNext { + c.Input.Cursor = o.Meta.NextCursor + continue + } + } + } + + return nil + } +} diff --git a/pkg/commands/secretstoreentry/root.go b/pkg/commands/secretstoreentry/root.go new file mode 100644 index 000000000..c993ba076 --- /dev/null +++ b/pkg/commands/secretstoreentry/root.go @@ -0,0 +1,39 @@ +package secretstoreentry + +import ( + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" +) + +// RootNameSecret is the base command name for secret operations. +const RootNameSecret = "secret-store-entry" + +// CommandName is the string to be used to invoke this command. +const CommandName = "secret-store-entry" + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent argparser.Registerer, g *global.Data) *RootCommand { + c := RootCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + + c.CmdClause = parent.Command(CommandName, "Manipulate Fastly Secret Store secrets") + + return &c +} + +// RootCommand is the parent command for all 'secret' subcommands. +// It should be installed under the primary root command. +type RootCommand struct { + argparser.Base + // no flags +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { + panic("unreachable") +} diff --git a/pkg/commands/secretstoreentry/secretstoreentry_test.go b/pkg/commands/secretstoreentry/secretstoreentry_test.go new file mode 100644 index 000000000..11de4af59 --- /dev/null +++ b/pkg/commands/secretstoreentry/secretstoreentry_test.go @@ -0,0 +1,552 @@ +package secretstoreentry_test + +import ( + "bytes" + "crypto/ed25519" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "io" + "net/http" + "os" + "path" + "runtime" + "testing" + "time" + + "golang.org/x/crypto/nacl/box" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/app" + "github.com/fastly/cli/pkg/commands/secretstoreentry" + fstfmt "github.com/fastly/cli/pkg/fmt" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" +) + +func TestCreateSecretCommand(t *testing.T) { + const ( + storeID = "store123" + secretName = "testsecret" + secretDigest = "digest" + secretValue = "the secret" + ) + + tmpDir := t.TempDir() + secretFile := path.Join(tmpDir, "secret-file") + if err := os.WriteFile(secretFile, []byte(secretValue), 0o600); err != nil { + t.Fatal(err) + } + doesNotExistFile := path.Join(tmpDir, "DOES-NOT-EXIST") + + ckPub, ckPriv, err := box.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + + skPub, skPriv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + + ck := &fastly.ClientKey{ + PublicKey: ckPub[:], + Signature: ed25519.Sign(skPriv, ckPub[:]), + ExpiresAt: time.Now().Add(time.Hour), + } + + mockCreateClientKey := func() (*fastly.ClientKey, error) { return ck, nil } + mockGetSigningKey := func() (ed25519.PublicKey, error) { return skPub, nil } + + decrypt := func(ciphertext []byte) (string, error) { + plaintext, ok := box.OpenAnonymous(nil, ciphertext, ckPub, ckPriv) + if !ok { + return "", errors.New("failed to decrypt") + } + return string(plaintext), nil + } + + scenarios := []struct { + args string + stdin string + api mock.API + wantAPIInvoked bool + wantError string + wantOutput string + }{ + { + args: "create --name test", + wantError: "error parsing arguments: required flag --store-id not provided", + }, + { + args: "create --store-id abc123", + wantError: "error parsing arguments: required flag --name not provided", + }, + { + args: fmt.Sprintf("create --store-id %s --name %s --file %s", storeID, secretName, doesNotExistFile), + wantError: func() string { + if runtime.GOOS == "windows" { + return "The system cannot find the file specified" + } + return "no such file or directory" + }(), + }, + { + args: fmt.Sprintf("create --store-id %s --name %s --stdin", storeID, secretName), + wantError: "unable to read from STDIN", + }, + { + args: fmt.Sprintf("create --store-id %s --name %s --stdin --recreate --recreate-allow", storeID, secretName), + wantError: "invalid flag combination, --recreate and --recreate-allow", + }, + // Read from STDIN. + { + args: fmt.Sprintf("create --store-id %s --name %s --stdin", storeID, secretName), + stdin: secretValue, + api: mock.API{ + CreateClientKeyFn: mockCreateClientKey, + GetSigningKeyFn: mockGetSigningKey, + CreateSecretFn: func(i *fastly.CreateSecretInput) (*fastly.Secret, error) { + if got, err := decrypt(i.Secret); err != nil { + return nil, err + } else if got != secretValue { + return nil, fmt.Errorf("invalid secret: %s", got) + } + return &fastly.Secret{ + Name: i.Name, + Digest: []byte(secretDigest), + }, nil + }, + }, + wantAPIInvoked: true, + wantOutput: fstfmt.Success("Created secret '%s' in Secret Store '%s' (digest: %s)", secretName, storeID, hex.EncodeToString([]byte(secretDigest))), + }, + // Read from file. + { + args: fmt.Sprintf("create --store-id %s --name %s --file %s", storeID, secretName, secretFile), + api: mock.API{ + CreateClientKeyFn: mockCreateClientKey, + GetSigningKeyFn: mockGetSigningKey, + CreateSecretFn: func(i *fastly.CreateSecretInput) (*fastly.Secret, error) { + if got, err := decrypt(i.Secret); err != nil { + return nil, err + } else if got != secretValue { + return nil, fmt.Errorf("invalid secret: %s", got) + } + return &fastly.Secret{ + Name: i.Name, + Digest: []byte(secretDigest), + }, nil + }, + }, + wantAPIInvoked: true, + wantOutput: fstfmt.Success("Created secret '%s' in Secret Store '%s' (digest: %s)", secretName, storeID, hex.EncodeToString([]byte(secretDigest))), + }, + { + args: fmt.Sprintf("create --store-id %s --name %s --file %s --json", storeID, secretName, secretFile), + api: mock.API{ + CreateClientKeyFn: mockCreateClientKey, + GetSigningKeyFn: mockGetSigningKey, + CreateSecretFn: func(i *fastly.CreateSecretInput) (*fastly.Secret, error) { + if got, err := decrypt(i.Secret); err != nil { + return nil, err + } else if got != secretValue { + return nil, fmt.Errorf("invalid secret: %s", got) + } + return &fastly.Secret{ + Name: i.Name, + Digest: []byte(secretDigest), + }, nil + }, + }, + wantAPIInvoked: true, + wantOutput: fstfmt.EncodeJSON(&fastly.Secret{ + Name: secretName, + Digest: []byte(secretDigest), + }), + }, + // CreateOrRecreate + { + args: fmt.Sprintf("create --store-id %s --name %s --file %s --json --recreate-allow", storeID, secretName, secretFile), + api: mock.API{ + CreateClientKeyFn: mockCreateClientKey, + GetSigningKeyFn: mockGetSigningKey, + CreateSecretFn: func(i *fastly.CreateSecretInput) (*fastly.Secret, error) { + if got, want := i.Method, http.MethodPut; got != want { + return nil, fmt.Errorf("got method %q, want %q", got, want) + } + if got, err := decrypt(i.Secret); err != nil { + return nil, err + } else if got != secretValue { + return nil, fmt.Errorf("invalid secret: %s", got) + } + return &fastly.Secret{ + Name: i.Name, + Digest: []byte(secretDigest), + }, nil + }, + }, + wantAPIInvoked: true, + wantOutput: fstfmt.EncodeJSON(&fastly.Secret{ + Name: secretName, + Digest: []byte(secretDigest), + }), + }, + // Recreate + { + args: fmt.Sprintf("create --store-id %s --name %s --file %s --json --recreate", storeID, secretName, secretFile), + api: mock.API{ + CreateClientKeyFn: mockCreateClientKey, + GetSigningKeyFn: mockGetSigningKey, + CreateSecretFn: func(i *fastly.CreateSecretInput) (*fastly.Secret, error) { + if got, want := i.Method, http.MethodPatch; got != want { + return nil, fmt.Errorf("got method %q, want %q", got, want) + } + if got, err := decrypt(i.Secret); err != nil { + return nil, err + } else if got != secretValue { + return nil, fmt.Errorf("invalid secret: %s", got) + } + return &fastly.Secret{ + Name: i.Name, + Digest: []byte(secretDigest), + Recreated: true, + }, nil + }, + }, + wantAPIInvoked: true, + wantOutput: fstfmt.EncodeJSON(&fastly.Secret{ + Name: secretName, + Digest: []byte(secretDigest), + Recreated: true, + }), + }, + } + + for _, testcase := range scenarios { + testcase := testcase + t.Run(testcase.args, func(t *testing.T) { + var stdout bytes.Buffer + args := testutil.SplitArgs(secretstoreentry.RootNameSecret + " " + testcase.args) + opts := testutil.MockGlobalData(args, &stdout) + if testcase.stdin != "" { + var stdin bytes.Buffer + stdin.WriteString(testcase.stdin) + opts.Input = &stdin + } + + f := testcase.api.CreateSecretFn + var apiInvoked bool + testcase.api.CreateSecretFn = func(i *fastly.CreateSecretInput) (*fastly.Secret, error) { + apiInvoked = true + return f(i) + } + + // Tests generate their own signing keys, which won't match + // the hardcoded value. Disable the check against the + // hardcoded value. + t.Setenv("FASTLY_USE_API_SIGNING_KEY", "1") + + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(args, nil) + + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertString(t, testcase.wantOutput, stdout.String()) + if apiInvoked != testcase.wantAPIInvoked { + t.Fatalf("API CreateSecret invoked = %v, want %v", apiInvoked, testcase.wantAPIInvoked) + } + }) + } +} + +func TestDeleteSecretCommand(t *testing.T) { + const ( + storeID = "test123" + secretName = "testName" + ) + + scenarios := []struct { + args string + api mock.API + wantAPIInvoked bool + wantError string + wantOutput string + }{ + { + args: "delete --name test", + wantError: "error parsing arguments: required flag --store-id not provided", + }, + { + args: "delete --store-id test", + wantError: "error parsing arguments: required flag --name not provided", + }, + { + args: fmt.Sprintf("delete --store-id %s --name DOES-NOT-EXIST", storeID), + api: mock.API{ + DeleteSecretFn: func(i *fastly.DeleteSecretInput) error { + if i.StoreID != storeID || i.Name != secretName { + return errors.New("not found") + } + return nil + }, + }, + wantAPIInvoked: true, + wantError: "not found", + }, + { + args: fmt.Sprintf("delete --store-id %s --name %s", storeID, secretName), + api: mock.API{ + DeleteSecretFn: func(i *fastly.DeleteSecretInput) error { + if i.StoreID != storeID || i.Name != secretName { + return errors.New("not found") + } + return nil + }, + }, + wantAPIInvoked: true, + wantOutput: fstfmt.Success("Deleted secret '%s' from Secret Store '%s'", secretName, storeID), + }, + { + args: fmt.Sprintf("delete --store-id %s --name %s --json", storeID, secretName), + api: mock.API{ + DeleteSecretFn: func(i *fastly.DeleteSecretInput) error { + if i.StoreID != storeID || i.Name != secretName { + return errors.New("not found") + } + return nil + }, + }, + wantAPIInvoked: true, + wantOutput: fstfmt.JSON(`{"name": %q, "store_id": %q, "deleted": true}`, secretName, storeID), + }, + } + + for _, testcase := range scenarios { + testcase := testcase + t.Run(testcase.args, func(t *testing.T) { + var stdout bytes.Buffer + args := testutil.SplitArgs(secretstoreentry.RootNameSecret + " " + testcase.args) + opts := testutil.MockGlobalData(args, &stdout) + + f := testcase.api.DeleteSecretFn + var apiInvoked bool + testcase.api.DeleteSecretFn = func(i *fastly.DeleteSecretInput) error { + apiInvoked = true + return f(i) + } + + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(args, nil) + + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertString(t, testcase.wantOutput, stdout.String()) + if apiInvoked != testcase.wantAPIInvoked { + t.Fatalf("API DeleteSecret invoked = %v, want %v", apiInvoked, testcase.wantAPIInvoked) + } + }) + } +} + +func TestDescribeSecretCommand(t *testing.T) { + const ( + storeID = "testid" + storeName = "testname" + storeDigest = "testdigest" + ) + + scenarios := []struct { + args string + api mock.API + wantAPIInvoked bool + wantError string + wantOutput string + }{ + { + args: "get --store-id abc", + wantError: "error parsing arguments: required flag --name not provided", + }, + { + args: "get --name abc", + wantError: "error parsing arguments: required flag --store-id not provided", + }, + { + args: fmt.Sprintf("get --store-id %s --name %s", "DOES-NOT-EXIST", storeName), + api: mock.API{ + GetSecretFn: func(i *fastly.GetSecretInput) (*fastly.Secret, error) { + if i.StoreID != storeID || i.Name != storeName { + return nil, errors.New("invalid request") + } + return &fastly.Secret{ + Name: storeName, + Digest: []byte(storeDigest), + }, nil + }, + }, + wantAPIInvoked: true, + wantError: "invalid request", + }, + { + args: fmt.Sprintf("get --store-id %s --name %s", storeID, storeName), + api: mock.API{ + GetSecretFn: func(i *fastly.GetSecretInput) (*fastly.Secret, error) { + if i.StoreID != storeID || i.Name != storeName { + return nil, errors.New("invalid request") + } + return &fastly.Secret{ + Name: storeName, + Digest: []byte(storeDigest), + }, nil + }, + }, + wantAPIInvoked: true, + wantOutput: fmtSecret(&fastly.Secret{ + Name: storeName, + Digest: []byte(storeDigest), + }), + }, + { + args: fmt.Sprintf("get --store-id %s --name %s --json", storeID, storeName), + api: mock.API{ + GetSecretFn: func(i *fastly.GetSecretInput) (*fastly.Secret, error) { + if i.StoreID != storeID || i.Name != storeName { + return nil, errors.New("invalid request") + } + return &fastly.Secret{ + Name: storeName, + Digest: []byte(storeDigest), + }, nil + }, + }, + wantAPIInvoked: true, + wantOutput: fstfmt.EncodeJSON(&fastly.Secret{ + Name: storeName, + Digest: []byte(storeDigest), + }), + }, + } + + for _, testcase := range scenarios { + testcase := testcase + t.Run(testcase.args, func(t *testing.T) { + var stdout bytes.Buffer + args := testutil.SplitArgs(secretstoreentry.RootNameSecret + " " + testcase.args) + opts := testutil.MockGlobalData(args, &stdout) + + f := testcase.api.GetSecretFn + var apiInvoked bool + testcase.api.GetSecretFn = func(i *fastly.GetSecretInput) (*fastly.Secret, error) { + apiInvoked = true + return f(i) + } + + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(args, nil) + + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertString(t, testcase.wantOutput, stdout.String()) + if apiInvoked != testcase.wantAPIInvoked { + t.Fatalf("API GetSecret invoked = %v, want %v", apiInvoked, testcase.wantAPIInvoked) + } + }) + } +} + +func TestListSecretsCommand(t *testing.T) { + const ( + secretName = "test123" + storeID = "store-id-123" + ) + + secrets := &fastly.Secrets{ + Meta: fastly.SecretStoreMeta{ + Limit: 123, + NextCursor: "abc", + }, + Data: []fastly.Secret{ + {Name: secretName, Digest: []byte(secretName)}, + }, + } + + scenarios := []struct { + args string + api mock.API + wantAPIInvoked bool + wantError string + wantOutput string + }{ + { + args: "list", + wantError: "required flag --store-id not provided", + }, + { + args: fmt.Sprintf("list --store-id %s", storeID), + api: mock.API{ + ListSecretsFn: func(_ *fastly.ListSecretsInput) (*fastly.Secrets, error) { + return secrets, errors.New("unknown error") + }, + }, + wantAPIInvoked: true, + wantError: "unknown error", + }, + { + args: fmt.Sprintf("list --store-id %s", storeID), + api: mock.API{ + ListSecretsFn: func(_ *fastly.ListSecretsInput) (*fastly.Secrets, error) { + return secrets, nil + }, + }, + wantAPIInvoked: true, + wantOutput: fmtSecrets(secrets), + }, + { + args: fmt.Sprintf("list --store-id %s --json", storeID), + api: mock.API{ + ListSecretsFn: func(_ *fastly.ListSecretsInput) (*fastly.Secrets, error) { + return secrets, nil + }, + }, + wantAPIInvoked: true, + wantOutput: fstfmt.EncodeJSON(secrets), + }, + } + + for _, testcase := range scenarios { + testcase := testcase + t.Run(testcase.args, func(t *testing.T) { + var stdout bytes.Buffer + args := testutil.SplitArgs(secretstoreentry.RootNameSecret + " " + testcase.args) + opts := testutil.MockGlobalData(args, &stdout) + + f := testcase.api.ListSecretsFn + var apiInvoked bool + testcase.api.ListSecretsFn = func(i *fastly.ListSecretsInput) (*fastly.Secrets, error) { + apiInvoked = true + return f(i) + } + + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(args, nil) + + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertString(t, testcase.wantOutput, stdout.String()) + if apiInvoked != testcase.wantAPIInvoked { + t.Fatalf("API ListSecrets invoked = %v, want %v", apiInvoked, testcase.wantAPIInvoked) + } + }) + } +} diff --git a/pkg/commands/service/create.go b/pkg/commands/service/create.go new file mode 100644 index 000000000..7baea5fde --- /dev/null +++ b/pkg/commands/service/create.go @@ -0,0 +1,64 @@ +package service + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// CreateCommand calls the Fastly API to create services. +type CreateCommand struct { + argparser.Base + + // Optional. + comment argparser.OptionalString + name argparser.OptionalString + stype argparser.OptionalString +} + +// NewCreateCommand returns a usable command registered under the parent. +func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { + c := CreateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("create", "Create a Fastly service").Alias("add") + + // Optional. + c.CmdClause.Flag("comment", "Human-readable comment").Action(c.comment.Set).StringVar(&c.comment.Value) + c.CmdClause.Flag("name", "Service name").Short('n').Action(c.name.Set).StringVar(&c.name.Value) + c.CmdClause.Flag("type", `Service type. Can be one of "wasm" or "vcl", defaults to "vcl".`).Default("vcl").Action(c.stype.Set).EnumVar(&c.stype.Value, "wasm", "vcl") + return &c +} + +// Exec invokes the application logic for the command. +func (c *CreateCommand) Exec(_ io.Reader, out io.Writer) error { + input := fastly.CreateServiceInput{} + + if c.name.WasSet { + input.Name = &c.name.Value + } + if c.comment.WasSet { + input.Comment = &c.comment.Value + } + if c.stype.WasSet { + input.Type = &c.stype.Value + } + s, err := c.Globals.APIClient.CreateService(&input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service Name": input.Name, + "Type": input.Type, + "Comment": input.Comment, + }) + return err + } + + text.Success(out, "Created service %s", fastly.ToValue(s.ServiceID)) + return nil +} diff --git a/pkg/commands/service/delete.go b/pkg/commands/service/delete.go new file mode 100644 index 000000000..a82a5f605 --- /dev/null +++ b/pkg/commands/service/delete.go @@ -0,0 +1,114 @@ +package service + +import ( + "fmt" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/text" +) + +// DeleteCommand calls the Fastly API to delete services. +type DeleteCommand struct { + argparser.Base + Input fastly.DeleteServiceInput + force bool + serviceName argparser.OptionalServiceNameID +} + +// NewDeleteCommand returns a usable command registered under the parent. +func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { + c := DeleteCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("delete", "Delete a Fastly service").Alias("remove") + + // Optional. + c.CmdClause.Flag("force", "Force deletion of an active service").Short('f').BoolVar(&c.force) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + return &c +} + +// Exec invokes the application logic for the command. +func (c *DeleteCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, source, flag, err := argparser.ServiceID(c.serviceName, *c.Globals.Manifest, c.Globals.APIClient, c.Globals.ErrLog) + if err != nil { + return err + } + if c.Globals.Verbose() { + argparser.DisplayServiceID(serviceID, flag, source, out) + } + + c.Input.ServiceID = serviceID + + if c.force { + s, err := c.Globals.APIClient.GetServiceDetails(&fastly.GetServiceInput{ + ServiceID: serviceID, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + }) + return err + } + + if s.ActiveVersion != nil && fastly.ToValue(s.ActiveVersion.Number) != 0 { + _, err := c.Globals.APIClient.DeactivateVersion(&fastly.DeactivateVersionInput{ + ServiceID: serviceID, + ServiceVersion: fastly.ToValue(s.ActiveVersion.Number), + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fastly.ToValue(s.ActiveVersion.Number), + }) + return err + } + } + } + + if err := c.Globals.APIClient.DeleteService(&c.Input); err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + }) + return errors.RemediationError{ + Inner: err, + Remediation: fmt.Sprintf("Try %s\n", text.Bold("fastly service delete --force")), + } + } + + // Ensure that VCL service users are unaffected by checking if the Service ID + // was acquired via the fastly.toml manifest. + if source == manifest.SourceFile { + if err := c.Globals.Manifest.File.Read(manifest.Filename); err != nil { + c.Globals.ErrLog.Add(err) + return fmt.Errorf("error reading fastly.toml: %w", err) + } + c.Globals.Manifest.File.ServiceID = "" + if err := c.Globals.Manifest.File.Write(manifest.Filename); err != nil { + c.Globals.ErrLog.Add(err) + return fmt.Errorf("error updating fastly.toml: %w", err) + } + } + + text.Success(out, "Deleted service ID %s", c.Input.ServiceID) + return nil +} diff --git a/pkg/commands/service/describe.go b/pkg/commands/service/describe.go new file mode 100644 index 000000000..d933b4550 --- /dev/null +++ b/pkg/commands/service/describe.go @@ -0,0 +1,114 @@ +package service + +import ( + "fmt" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/text" + "github.com/fastly/cli/pkg/time" +) + +// DescribeCommand calls the Fastly API to describe a service. +type DescribeCommand struct { + argparser.Base + argparser.JSONOutput + + Input fastly.GetServiceInput + serviceName argparser.OptionalServiceNameID +} + +// NewDescribeCommand returns a usable command registered under the parent. +func NewDescribeCommand(parent argparser.Registerer, g *global.Data) *DescribeCommand { + c := DescribeCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("describe", "Show detailed information about a Fastly service").Alias("get") + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + return &c +} + +// Exec invokes the application logic for the command. +func (c *DescribeCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + serviceID, source, flag, err := argparser.ServiceID(c.serviceName, *c.Globals.Manifest, c.Globals.APIClient, c.Globals.ErrLog) + if err != nil { + return err + } + if c.Globals.Verbose() { + argparser.DisplayServiceID(serviceID, flag, source, out) + } + + if source == manifest.SourceUndefined && !c.serviceName.WasSet { + err := fsterr.ErrNoServiceID + c.Globals.ErrLog.Add(err) + return err + } + + c.Input.ServiceID = serviceID + + o, err := c.Globals.APIClient.GetServiceDetails(&c.Input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + }) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + return c.print(o, out) +} + +func (c *DescribeCommand) print(s *fastly.ServiceDetail, out io.Writer) error { + fmt.Fprintf(out, "ID: %s\n", fastly.ToValue(s.ServiceID)) + fmt.Fprintf(out, "Name: %s\n", fastly.ToValue(s.Name)) + fmt.Fprintf(out, "Type: %s\n", fastly.ToValue(s.Type)) + fmt.Fprintf(out, "Comment: %s\n", fastly.ToValue(s.Comment)) + fmt.Fprintf(out, "Customer ID: %s\n", fastly.ToValue(s.CustomerID)) + if s.CreatedAt != nil { + fmt.Fprintf(out, "Created (UTC): %s\n", s.CreatedAt.UTC().Format(time.Format)) + } + if s.UpdatedAt != nil { + fmt.Fprintf(out, "Last edited (UTC): %s\n", s.UpdatedAt.UTC().Format(time.Format)) + } + if s.DeletedAt != nil { + fmt.Fprintf(out, "Deleted (UTC): %s\n", s.DeletedAt.UTC().Format(time.Format)) + } + if s.ActiveVersion != nil { + fmt.Fprintf(out, "Active version:\n") + text.PrintVersion(out, "\t", s.ActiveVersion) + } + fmt.Fprintf(out, "Versions: %d\n", len(s.Versions)) + for j, version := range s.Versions { + fmt.Fprintf(out, "\tVersion %d/%d\n", j+1, len(s.Versions)) + text.PrintVersion(out, "\t\t", version) + } + return nil +} diff --git a/pkg/service/doc.go b/pkg/commands/service/doc.go similarity index 100% rename from pkg/service/doc.go rename to pkg/commands/service/doc.go diff --git a/pkg/commands/service/list.go b/pkg/commands/service/list.go new file mode 100644 index 000000000..3f6b08495 --- /dev/null +++ b/pkg/commands/service/list.go @@ -0,0 +1,109 @@ +package service + +import ( + "fmt" + "io" + "strconv" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" + "github.com/fastly/cli/pkg/time" +) + +// ListCommand calls the Fastly API to list services. +type ListCommand struct { + argparser.Base + argparser.JSONOutput + + direction string + page, perPage int + input fastly.GetServicesInput + sort string +} + +// NewListCommand returns a usable command registered under the parent. +func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { + c := ListCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("list", "List Fastly services") + + // Optional. + c.CmdClause.Flag("direction", "Direction in which to sort results").Default(argparser.PaginationDirection[0]).HintOptions(argparser.PaginationDirection...).EnumVar(&c.direction, argparser.PaginationDirection...) + c.RegisterFlagBool(c.JSONFlag()) // --json + c.CmdClause.Flag("page", "Page number of data set to fetch").IntVar(&c.page) + c.CmdClause.Flag("per-page", "Number of records per page").IntVar(&c.perPage) + c.CmdClause.Flag("sort", "Field on which to sort").Default("created").StringVar(&c.sort) + return &c +} + +// Exec invokes the application logic for the command. +func (c *ListCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + c.input.Direction = &c.direction + c.input.Page = &c.page + c.input.PerPage = &c.perPage + c.input.Sort = &c.sort + paginator := c.Globals.APIClient.GetServices(&c.input) + + var o []*fastly.Service + for paginator.HasNext() { + data, err := paginator.GetNext() + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Remaining Pages": paginator.Remaining(), + }) + return err + } + o = append(o, data...) + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + if !c.Globals.Verbose() { + tw := text.NewTable(out) + tw.AddHeader("NAME", "ID", "TYPE", "ACTIVE VERSION", "LAST EDITED (UTC)") + for _, service := range o { + updatedAt := "n/a" + if service.UpdatedAt != nil { + updatedAt = service.UpdatedAt.UTC().Format(time.Format) + } + + activeVersion := strconv.Itoa(fastly.ToValue(service.ActiveVersion)) + for _, v := range service.Versions { + if fastly.ToValue(v.Number) == fastly.ToValue(service.ActiveVersion) && !fastly.ToValue(v.Active) { + activeVersion = "n/a" + } + } + + tw.AddLine( + fastly.ToValue(service.Name), + fastly.ToValue(service.ServiceID), + fastly.ToValue(service.Type), + activeVersion, + updatedAt, + ) + } + tw.Print() + return nil + } + + for i, service := range o { + fmt.Fprintf(out, "Service %d/%d\n", i+1, len(o)) + text.PrintService(out, "\t", service) + fmt.Fprintln(out) + } + + return nil +} diff --git a/pkg/commands/service/root.go b/pkg/commands/service/root.go new file mode 100644 index 000000000..51a556557 --- /dev/null +++ b/pkg/commands/service/root.go @@ -0,0 +1,31 @@ +package service + +import ( + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" +) + +// RootCommand is the parent command for all subcommands in this package. +// It should be installed under the primary root command. +type RootCommand struct { + argparser.Base + // no flags +} + +// CommandName is the string to be used to invoke this command. +const CommandName = "service" + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent argparser.Registerer, g *global.Data) *RootCommand { + var c RootCommand + c.Globals = g + c.CmdClause = parent.Command(CommandName, "Manipulate Fastly services") + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { + panic("unreachable") +} diff --git a/pkg/commands/service/search.go b/pkg/commands/service/search.go new file mode 100644 index 000000000..60b7f8612 --- /dev/null +++ b/pkg/commands/service/search.go @@ -0,0 +1,60 @@ +package service + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// SearchCommand calls the Fastly API to describe a service. +type SearchCommand struct { + argparser.Base + argparser.JSONOutput + + Input fastly.SearchServiceInput +} + +// NewSearchCommand returns a usable command registered under the parent. +func NewSearchCommand(parent argparser.Registerer, g *global.Data) *SearchCommand { + c := SearchCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("search", "Search for a Fastly service by name") + + // Required. + c.CmdClause.Flag("name", "Service name").Short('n').Required().StringVar(&c.Input.Name) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + + return &c +} + +// Exec invokes the application logic for the command. +func (c *SearchCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + service, err := c.Globals.APIClient.SearchService(&c.Input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service Name": c.Input.Name, + }) + return err + } + + if ok, err := c.WriteJSON(out, service); ok { + return err + } + + text.PrintService(out, "", service) + return nil +} diff --git a/pkg/commands/service/service_test.go b/pkg/commands/service/service_test.go new file mode 100644 index 000000000..77260ed6d --- /dev/null +++ b/pkg/commands/service/service_test.go @@ -0,0 +1,797 @@ +package service_test + +import ( + "bytes" + "errors" + "io" + "net/http" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/app" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" +) + +func TestServiceCreate(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("service create --name Foo"), + api: mock.API{CreateServiceFn: createServiceOK}, + wantOutput: "Created service 12345", + }, + { + args: args("service create -n=Foo"), + api: mock.API{CreateServiceFn: createServiceOK}, + wantOutput: "Created service 12345", + }, + { + args: args("service create --name Foo --type wasm"), + api: mock.API{CreateServiceFn: createServiceOK}, + wantOutput: "Created service 12345", + }, + { + args: args("service create --name Foo --type wasm --comment Hello"), + api: mock.API{CreateServiceFn: createServiceOK}, + wantOutput: "Created service 12345", + }, + { + args: args("service create -n Foo --comment Hello"), + api: mock.API{CreateServiceFn: createServiceOK}, + wantOutput: "Created service 12345", + }, + { + args: args("service create -n Foo"), + api: mock.API{CreateServiceFn: createServiceError}, + wantError: errTest.Error(), + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertStringContains(t, stdout.String(), testcase.wantOutput) + }) + } +} + +func TestServiceList(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + api: mock.API{ + GetServicesFn: func(_ *fastly.GetServicesInput) *fastly.ListPaginator[fastly.Service] { + return fastly.NewPaginator[fastly.Service](&mock.HTTPClient{ + Errors: []error{ + testutil.Err, + }, + Responses: []*http.Response{nil}, + }, fastly.ListOpts{}, "/example") + }, + }, + args: args("service list"), + wantError: testutil.Err.Error(), + }, + { + api: mock.API{ + GetServicesFn: func(_ *fastly.GetServicesInput) *fastly.ListPaginator[fastly.Service] { + return fastly.NewPaginator[fastly.Service](&mock.HTTPClient{ + Errors: []error{nil}, + Responses: []*http.Response{ + { + Body: io.NopCloser(strings.NewReader(`[ + { + "name": "Foo", + "id": "123", + "type": "wasm", + "version": 2, + "updated_at": "2021-06-15T23:00:00Z" + }, + { + "name": "Bar", + "id": "456", + "type": "wasm", + "version": 1, + "updated_at": "2021-06-15T23:00:00Z" + }, + { + "name": "Baz", + "id": "789", + "type": "vcl", + "version": 1 + } + ]`)), + }, + }, + }, fastly.ListOpts{}, "/example") + }, + }, + args: args("service list --per-page 1"), + wantOutput: listServicesShortOutput, + }, + { + api: mock.API{ + GetServicesFn: func(_ *fastly.GetServicesInput) *fastly.ListPaginator[fastly.Service] { + return fastly.NewPaginator[fastly.Service](&mock.HTTPClient{ + Errors: []error{nil}, + Responses: []*http.Response{ + { + Body: io.NopCloser(strings.NewReader(`[ + { + "name": "Foo", + "id": "123", + "type": "wasm", + "version": 2, + "updated_at": "2021-06-15T23:00:00Z", + "customer_id": "mycustomerid", + "versions": [ + { + "number": 1, + "comment": "a", + "service_id": "b", + "active": false, + "locked": false, + "deployed": false, + "staging": false, + "testing": false, + "created_at": "2021-06-15T23:00:00Z", + "deleted_at": "2021-06-15T23:00:00Z", + "updated_at": "2021-06-15T23:00:00Z" + }, + { + "number": 2, + "comment": "c", + "service_id": "d", + "active": true, + "locked": false, + "deployed": true, + "staging": false, + "testing": false, + "created_at": "2021-06-15T23:00:00Z", + "updated_at": "2021-06-15T23:00:00Z" + } + ] + }, + { + "name": "Bar", + "id": "456", + "type": "wasm", + "version": 1, + "updated_at": "2021-06-15T23:00:00Z", + "customer_id": "mycustomerid" + }, + { + "name": "Baz", + "id": "789", + "type": "vcl", + "version": 1, + "customer_id": "mycustomerid" + } + ]`)), + }, + }, + }, fastly.ListOpts{}, "/example") + }, + }, + args: args("service list --verbose"), + wantOutput: listServicesVerboseOutput, + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertString(t, testcase.wantOutput, stdout.String()) + }) + } +} + +func TestServiceDescribe(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("service describe"), + api: mock.API{GetServiceDetailsFn: describeServiceOK}, + wantError: "error reading service: no service ID found", + }, + { + args: args("service describe --service-id 123"), + api: mock.API{GetServiceDetailsFn: describeServiceOK}, + wantOutput: describeServiceShortOutput, + }, + { + args: args("service describe --service-id 123 --verbose"), + api: mock.API{GetServiceDetailsFn: describeServiceOK}, + wantOutput: describeServiceVerboseOutput, + }, + { + args: args("service describe --service-id 123 -v"), + api: mock.API{GetServiceDetailsFn: describeServiceOK}, + wantOutput: describeServiceVerboseOutput, + }, + { + args: args("service --verbose describe --service-id 123"), + api: mock.API{GetServiceDetailsFn: describeServiceOK}, + wantOutput: describeServiceVerboseOutput, + }, + { + args: args("-v service describe --service-id 123"), + api: mock.API{GetServiceDetailsFn: describeServiceOK}, + wantOutput: describeServiceVerboseOutput, + }, + { + args: args("service describe --service-id 123"), + api: mock.API{GetServiceDetailsFn: describeServiceError}, + wantError: errTest.Error(), + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertString(t, testcase.wantOutput, stdout.String()) + }) + } +} + +func TestServiceSearch(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("service search"), + wantError: "error parsing arguments: required flag --name not provided", + }, + { + args: args("service search --name Foo"), + api: mock.API{SearchServiceFn: searchServiceOK}, + wantOutput: searchServiceShortOutput, + }, + { + args: args("service search --name Foo -v"), + api: mock.API{SearchServiceFn: searchServiceOK}, + wantOutput: searchServiceVerboseOutput, + }, + { + args: args("service search --name"), + api: mock.API{SearchServiceFn: searchServiceOK}, + wantError: "error parsing arguments: expected argument for flag '--name'", + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertString(t, testcase.wantOutput, stdout.String()) + }) + } +} + +func TestServiceUpdate(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("service update"), + api: mock.API{ + GetServiceFn: getServiceOK, + UpdateServiceFn: updateServiceOK, + }, + wantError: "error reading service: no service ID found", + }, + { + args: args("service update --service-id 12345"), + api: mock.API{UpdateServiceFn: updateServiceOK}, + wantError: "error parsing arguments: must provide either --name or --comment to update service", + }, + { + args: args("service update --service-id 12345 --name Foo"), + api: mock.API{UpdateServiceFn: updateServiceOK}, + wantOutput: "Updated service 12345", + }, + { + args: args("service update --service-id 12345 -n=Foo"), + api: mock.API{UpdateServiceFn: updateServiceOK}, + wantOutput: "Updated service 12345", + }, + { + args: args("service update --service-id 12345 --name Foo"), + api: mock.API{UpdateServiceFn: updateServiceOK}, + wantOutput: "Updated service 12345", + }, + { + args: args("service update --service-id 12345 --name Foo --comment Hello"), + api: mock.API{UpdateServiceFn: updateServiceOK}, + wantOutput: "Updated service 12345", + }, + { + args: args("service update --service-id 12345 -n Foo --comment Hello"), + api: mock.API{UpdateServiceFn: updateServiceOK}, + wantOutput: "Updated service 12345", + }, + { + args: args("service update --service-id 12345 -n Foo"), + api: mock.API{UpdateServiceFn: updateServiceError}, + wantError: errTest.Error(), + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertStringContains(t, stdout.String(), testcase.wantOutput) + }) + } +} + +func TestServiceDelete(t *testing.T) { + args := testutil.SplitArgs + nonEmptyServiceID := regexp.MustCompile(`service_id = "[^"]+"`) + + scenarios := []struct { + args []string + api mock.API + manifest string + wantError string + wantOutput string + expectEmptyServiceID bool + }{ + { + args: args("service delete"), + api: mock.API{DeleteServiceFn: deleteServiceOK}, + manifest: "fastly-no-serviceid.toml", + wantError: "error reading service: no service ID found", + }, + { + args: args("service delete"), + api: mock.API{DeleteServiceFn: deleteServiceOK}, + manifest: "fastly-valid.toml", + wantOutput: "Deleted service ID 123", + expectEmptyServiceID: true, + }, + { + args: args("service delete --service-id 001"), + api: mock.API{DeleteServiceFn: deleteServiceOK}, + wantOutput: "Deleted service ID 001", + }, + { + args: args("service delete --service-id 001"), + api: mock.API{DeleteServiceFn: deleteServiceOK}, + manifest: "fastly-valid.toml", + wantOutput: "Deleted service ID 001", + expectEmptyServiceID: false, + }, + { + args: args("service delete --service-id 001"), + api: mock.API{DeleteServiceFn: deleteServiceError}, + manifest: "fastly-valid.toml", + wantError: errTest.Error(), + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + // We're going to chdir to an temp environment, + // so save the PWD to return to, afterwards. + pwd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + + // Create test environment + opts := testutil.EnvOpts{T: t} + if testcase.manifest != "" { + b, err := os.ReadFile(filepath.Join("testdata", testcase.manifest)) + if err != nil { + t.Fatal(err) + } + opts.Write = []testutil.FileIO{ + {Src: string(b), Dst: manifest.Filename}, + } + } + rootdir := testutil.NewEnv(opts) + defer os.RemoveAll(rootdir) + + // Before running the test, chdir into the temp environment. + // When we're done, chdir back to our original location. + // This is so we can reliably assert file structure. + if err := os.Chdir(rootdir); err != nil { + t.Fatal(err) + } + defer func() { + _ = os.Chdir(pwd) + }() + + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + runOpts := testutil.MockGlobalData(testcase.args, &stdout) + runOpts.APIClientFactory = mock.APIClient(testcase.api) + return runOpts, nil + } + runErr := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, runErr, testcase.wantError) + testutil.AssertStringContains(t, stdout.String(), testcase.wantOutput) + + if testcase.manifest != "" { + m := filepath.Join(rootdir, manifest.Filename) + b, err := os.ReadFile(m) + if err != nil { + t.Fatal(err) + } + + if testcase.expectEmptyServiceID { + testutil.AssertStringContains(t, string(b), `service_id = ""`) + } else if !nonEmptyServiceID.Match(b) && runErr == nil { + // The runErr check is to prevent the first test case from causing an + // accidental failure. As the fastly.toml doesn't have a service_id + // set, while marshalling back and forth it'll get converted to an + // empty string in the manifest file which will accidentally trigger + // the following test error otherwise if we don't check for the nil + // error value. Because that first test case expects an error to be + // raised we know that we can safely check for `runErr == nil` here. + t.Fatal("expected service_id to contain a value") + } + } + }) + } +} + +var errTest = errors.New("fixture error") + +func createServiceOK(i *fastly.CreateServiceInput) (*fastly.Service, error) { + return &fastly.Service{ + ServiceID: fastly.ToPointer("12345"), + Name: i.Name, + }, nil +} + +func createServiceError(*fastly.CreateServiceInput) (*fastly.Service, error) { + return nil, errTest +} + +var listServicesShortOutput = strings.TrimSpace(` +NAME ID TYPE ACTIVE VERSION LAST EDITED (UTC) +Foo 123 wasm 2 2021-06-15 23:00 +Bar 456 wasm 1 2021-06-15 23:00 +Baz 789 vcl 1 n/a +`) + "\n" + +var listServicesVerboseOutput = strings.TrimSpace(` +Fastly API endpoint: https://api.fastly.com +Fastly API token provided via config file (profile: user) + +Service 1/3 + ID: 123 + Name: Foo + Type: wasm + Customer ID: mycustomerid + Last edited (UTC): 2021-06-15 23:00 + Active version: 2 + Versions: 2 + Version 1/2 + Number: 1 + Comment: a + Service ID: b + Active: false + Locked: false + Deployed: false + Staged: false + Testing: false + Created (UTC): 2021-06-15 23:00 + Last edited (UTC): 2021-06-15 23:00 + Deleted (UTC): 2021-06-15 23:00 + Version 2/2 + Number: 2 + Comment: c + Service ID: d + Active: true + Locked: false + Deployed: true + Staged: false + Testing: false + Created (UTC): 2021-06-15 23:00 + Last edited (UTC): 2021-06-15 23:00 + +Service 2/3 + ID: 456 + Name: Bar + Type: wasm + Customer ID: mycustomerid + Last edited (UTC): 2021-06-15 23:00 + Active version: 1 + Versions: 0 + +Service 3/3 + ID: 789 + Name: Baz + Type: vcl + Customer ID: mycustomerid + Active version: 1 + Versions: 0 +`) + "\n\n" + +func getServiceOK(_ *fastly.GetServiceInput) (*fastly.Service, error) { + return &fastly.Service{ + ServiceID: fastly.ToPointer("12345"), + Name: fastly.ToPointer("Foo"), + Comment: fastly.ToPointer("Bar"), + }, nil +} + +func describeServiceOK(_ *fastly.GetServiceInput) (*fastly.ServiceDetail, error) { + return &fastly.ServiceDetail{ + ServiceID: fastly.ToPointer("123"), + Name: fastly.ToPointer("Foo"), + Type: fastly.ToPointer("wasm"), + Comment: fastly.ToPointer("example"), + CustomerID: fastly.ToPointer("mycustomerid"), + ActiveVersion: &fastly.Version{ + Number: fastly.ToPointer(2), + Comment: fastly.ToPointer("c"), + ServiceID: fastly.ToPointer("d"), + Active: fastly.ToPointer(true), + Deployed: fastly.ToPointer(true), + CreatedAt: testutil.MustParseTimeRFC3339("2001-03-03T04:05:06Z"), + UpdatedAt: testutil.MustParseTimeRFC3339("2001-03-04T04:05:06Z"), + }, + UpdatedAt: testutil.MustParseTimeRFC3339("2010-11-15T19:01:02Z"), + Versions: []*fastly.Version{ + { + Number: fastly.ToPointer(1), + Comment: fastly.ToPointer("a"), + ServiceID: fastly.ToPointer("b"), + CreatedAt: testutil.MustParseTimeRFC3339("2001-02-03T04:05:06Z"), + UpdatedAt: testutil.MustParseTimeRFC3339("2001-02-04T04:05:06Z"), + DeletedAt: testutil.MustParseTimeRFC3339("2001-02-05T04:05:06Z"), + }, + { + Number: fastly.ToPointer(2), + Comment: fastly.ToPointer("c"), + ServiceID: fastly.ToPointer("d"), + Active: fastly.ToPointer(true), + Deployed: fastly.ToPointer(true), + CreatedAt: testutil.MustParseTimeRFC3339("2001-03-03T04:05:06Z"), + UpdatedAt: testutil.MustParseTimeRFC3339("2001-03-04T04:05:06Z"), + }, + }, + }, nil +} + +func describeServiceError(_ *fastly.GetServiceInput) (*fastly.ServiceDetail, error) { + return nil, errTest +} + +var describeServiceShortOutput = strings.TrimSpace(` +ID: 123 +Name: Foo +Type: wasm +Comment: example +Customer ID: mycustomerid +Last edited (UTC): 2010-11-15 19:01 +Active version: + Number: 2 + Comment: c + Service ID: d + Active: true + Deployed: true + Created (UTC): 2001-03-03 04:05 + Last edited (UTC): 2001-03-04 04:05 +Versions: 2 + Version 1/2 + Number: 1 + Comment: a + Service ID: b + Created (UTC): 2001-02-03 04:05 + Last edited (UTC): 2001-02-04 04:05 + Deleted (UTC): 2001-02-05 04:05 + Version 2/2 + Number: 2 + Comment: c + Service ID: d + Active: true + Deployed: true + Created (UTC): 2001-03-03 04:05 + Last edited (UTC): 2001-03-04 04:05 +`) + "\n" + +var describeServiceVerboseOutput = strings.TrimSpace(` +Fastly API endpoint: https://api.fastly.com +Fastly API token provided via config file (profile: user) + +Service ID (via --service-id): 123 + +ID: 123 +Name: Foo +Type: wasm +Comment: example +Customer ID: mycustomerid +Last edited (UTC): 2010-11-15 19:01 +Active version: + Number: 2 + Comment: c + Service ID: d + Active: true + Deployed: true + Created (UTC): 2001-03-03 04:05 + Last edited (UTC): 2001-03-04 04:05 +Versions: 2 + Version 1/2 + Number: 1 + Comment: a + Service ID: b + Created (UTC): 2001-02-03 04:05 + Last edited (UTC): 2001-02-04 04:05 + Deleted (UTC): 2001-02-05 04:05 + Version 2/2 + Number: 2 + Comment: c + Service ID: d + Active: true + Deployed: true + Created (UTC): 2001-03-03 04:05 + Last edited (UTC): 2001-03-04 04:05 +`) + "\n" + +func searchServiceOK(_ *fastly.SearchServiceInput) (*fastly.Service, error) { + return &fastly.Service{ + ServiceID: fastly.ToPointer("123"), + Name: fastly.ToPointer("Foo"), + Type: fastly.ToPointer("wasm"), + CustomerID: fastly.ToPointer("mycustomerid"), + UpdatedAt: testutil.MustParseTimeRFC3339("2010-11-15T19:01:02Z"), + Versions: []*fastly.Version{ + { + Number: fastly.ToPointer(1), + Comment: fastly.ToPointer("a"), + ServiceID: fastly.ToPointer("b"), + CreatedAt: testutil.MustParseTimeRFC3339("2001-02-03T04:05:06Z"), + UpdatedAt: testutil.MustParseTimeRFC3339("2001-02-04T04:05:06Z"), + DeletedAt: testutil.MustParseTimeRFC3339("2001-02-05T04:05:06Z"), + }, + { + Number: fastly.ToPointer(2), + Comment: fastly.ToPointer("c"), + ServiceID: fastly.ToPointer("d"), + Active: fastly.ToPointer(true), + Deployed: fastly.ToPointer(true), + CreatedAt: testutil.MustParseTimeRFC3339("2001-03-03T04:05:06Z"), + UpdatedAt: testutil.MustParseTimeRFC3339("2001-03-04T04:05:06Z"), + }, + }, + }, nil +} + +var searchServiceShortOutput = strings.TrimSpace(` +ID: 123 +Name: Foo +Type: wasm +Customer ID: mycustomerid +Last edited (UTC): 2010-11-15 19:01 +Versions: 2 + Version 1/2 + Number: 1 + Comment: a + Service ID: b + Created (UTC): 2001-02-03 04:05 + Last edited (UTC): 2001-02-04 04:05 + Deleted (UTC): 2001-02-05 04:05 + Version 2/2 + Number: 2 + Comment: c + Service ID: d + Active: true + Deployed: true + Created (UTC): 2001-03-03 04:05 + Last edited (UTC): 2001-03-04 04:05 +`) + "\n" + +var searchServiceVerboseOutput = strings.TrimSpace(` +Fastly API endpoint: https://api.fastly.com +Fastly API token provided via config file (profile: user) + +ID: 123 +Name: Foo +Type: wasm +Customer ID: mycustomerid +Last edited (UTC): 2010-11-15 19:01 +Versions: 2 + Version 1/2 + Number: 1 + Comment: a + Service ID: b + Created (UTC): 2001-02-03 04:05 + Last edited (UTC): 2001-02-04 04:05 + Deleted (UTC): 2001-02-05 04:05 + Version 2/2 + Number: 2 + Comment: c + Service ID: d + Active: true + Deployed: true + Created (UTC): 2001-03-03 04:05 + Last edited (UTC): 2001-03-04 04:05 +`) + "\n" + +func updateServiceOK(_ *fastly.UpdateServiceInput) (*fastly.Service, error) { + return &fastly.Service{ + ServiceID: fastly.ToPointer("12345"), + }, nil +} + +func updateServiceError(*fastly.UpdateServiceInput) (*fastly.Service, error) { + return nil, errTest +} + +func deleteServiceOK(*fastly.DeleteServiceInput) error { + return nil +} + +func deleteServiceError(*fastly.DeleteServiceInput) error { + return errTest +} diff --git a/pkg/commands/service/testdata/fastly-no-serviceid.toml b/pkg/commands/service/testdata/fastly-no-serviceid.toml new file mode 100644 index 000000000..99b315130 --- /dev/null +++ b/pkg/commands/service/testdata/fastly-no-serviceid.toml @@ -0,0 +1,5 @@ +manifest_version = 2 +name = "Default Rust template" +description = "Default package template for Rust based edge compute projects." +authors = ["phamann "] +language = "rust" diff --git a/pkg/commands/service/testdata/fastly-valid.toml b/pkg/commands/service/testdata/fastly-valid.toml new file mode 100644 index 000000000..1411c1dde --- /dev/null +++ b/pkg/commands/service/testdata/fastly-valid.toml @@ -0,0 +1,6 @@ +manifest_version = 2 +name = "Default Rust template" +description = "Default package template for Rust based edge compute projects." +authors = ["phamann "] +language = "rust" +service_id = "123" diff --git a/pkg/commands/service/update.go b/pkg/commands/service/update.go new file mode 100644 index 000000000..6d200c8f5 --- /dev/null +++ b/pkg/commands/service/update.go @@ -0,0 +1,86 @@ +package service + +import ( + "fmt" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// UpdateCommand calls the Fastly API to create services. +type UpdateCommand struct { + argparser.Base + + comment argparser.OptionalString + input fastly.UpdateServiceInput + name argparser.OptionalString + serviceName argparser.OptionalServiceNameID +} + +// NewUpdateCommand returns a usable command registered under the parent. +func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { + c := UpdateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("update", "Update a Fastly service") + + // Optional. + c.CmdClause.Flag("comment", "Human-readable comment").Action(c.comment.Set).StringVar(&c.comment.Value) + c.CmdClause.Flag("name", "Service name").Short('n').Action(c.name.Set).StringVar(&c.name.Value) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + return &c +} + +// Exec invokes the application logic for the command. +func (c *UpdateCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, source, flag, err := argparser.ServiceID(c.serviceName, *c.Globals.Manifest, c.Globals.APIClient, c.Globals.ErrLog) + if err != nil { + return err + } + if c.Globals.Verbose() { + argparser.DisplayServiceID(serviceID, flag, source, out) + } + + c.input.ServiceID = serviceID + + if !c.name.WasSet && !c.comment.WasSet { + return fmt.Errorf("error parsing arguments: must provide either --name or --comment to update service") + } + + if c.name.WasSet { + c.input.Name = &c.name.Value + } + if c.comment.WasSet { + c.input.Comment = &c.comment.Value + } + + s, err := c.Globals.APIClient.UpdateService(&c.input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Name": c.name.Value, + "Comment": c.comment.Value, + }) + return err + } + + text.Success(out, "Updated service %s", fastly.ToValue(s.ServiceID)) + return nil +} diff --git a/pkg/commands/serviceauth/create.go b/pkg/commands/serviceauth/create.go new file mode 100644 index 000000000..706c85bda --- /dev/null +++ b/pkg/commands/serviceauth/create.go @@ -0,0 +1,88 @@ +package serviceauth + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// Permissions is a list of supported permission values. +// https://www.fastly.com/documentation/reference/api/account/service-authorization#data-model +var Permissions = []string{"full", "read_only", "purge_select", "purge_all"} + +// CreateCommand calls the Fastly API to create a service authorization. +type CreateCommand struct { + argparser.Base + input fastly.CreateServiceAuthorizationInput + serviceName argparser.OptionalServiceNameID + userID string +} + +// NewCreateCommand returns a usable command registered under the parent. +func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { + c := CreateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("create", "Create service authorization").Alias("add") + + // Required. + c.CmdClause.Flag("user-id", "Alphanumeric string identifying the user").Required().Short('u').StringVar(&c.userID) + + // Optional. + // NOTE: We default to 'read_only' for security reasons. + // The API otherwise defaults to 'full' permissions! + c.CmdClause.Flag("permission", "The permission the user has in relation to the service (default: read_only)").HintOptions(Permissions...).Default("read_only").Short('p').EnumVar(&c.input.Permission, Permissions...) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + return &c +} + +// Exec invokes the application logic for the command. +func (c *CreateCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, source, flag, err := argparser.ServiceID(c.serviceName, *c.Globals.Manifest, c.Globals.APIClient, c.Globals.ErrLog) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": c.Globals.Manifest.Flag.ServiceID, + "Service Name": c.serviceName.Value, + }) + return err + } + if c.Globals.Flags.Verbose { + argparser.DisplayServiceID(serviceID, flag, source, out) + } + + c.input.Service = &fastly.SAService{ + ID: serviceID, + } + c.input.User = &fastly.SAUser{ + ID: c.userID, + } + + s, err := c.Globals.APIClient.CreateServiceAuthorization(&c.input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Flag": flag, + }) + return err + } + + text.Success(out, "Created service authorization %s", s.ID) + return nil +} diff --git a/pkg/commands/serviceauth/delete.go b/pkg/commands/serviceauth/delete.go new file mode 100644 index 000000000..3fe05e911 --- /dev/null +++ b/pkg/commands/serviceauth/delete.go @@ -0,0 +1,44 @@ +package serviceauth + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// DeleteCommand calls the Fastly API to delete service authorizations. +type DeleteCommand struct { + argparser.Base + Input fastly.DeleteServiceAuthorizationInput +} + +// NewDeleteCommand returns a usable command registered under the parent. +func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { + c := DeleteCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("delete", "Delete service authorization").Alias("remove") + + // Required. + c.CmdClause.Flag("id", "ID of the service authorization to delete").Required().StringVar(&c.Input.ID) + return &c +} + +// Exec invokes the application logic for the command. +func (c *DeleteCommand) Exec(_ io.Reader, out io.Writer) error { + if err := c.Globals.APIClient.DeleteServiceAuthorization(&c.Input); err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service Authorization ID": c.Input.ID, + }) + return err + } + + text.Success(out, "Deleted service authorization %s", c.Input.ID) + return nil +} diff --git a/pkg/commands/serviceauth/describe.go b/pkg/commands/serviceauth/describe.go new file mode 100644 index 000000000..052124a77 --- /dev/null +++ b/pkg/commands/serviceauth/describe.go @@ -0,0 +1,78 @@ +package serviceauth + +import ( + "fmt" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/time" +) + +// DescribeCommand calls the Fastly API to describe a service authorization. +type DescribeCommand struct { + argparser.Base + argparser.JSONOutput + + Input fastly.GetServiceAuthorizationInput +} + +// NewDescribeCommand returns a usable command registered under the parent. +func NewDescribeCommand(parent argparser.Registerer, g *global.Data) *DescribeCommand { + c := DescribeCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("describe", "Show service authorization").Alias("get") + + // Required. + c.CmdClause.Flag("id", "ID of the service authorization to retrieve").Required().StringVar(&c.Input.ID) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + return &c +} + +// Exec invokes the application logic for the command. +func (c *DescribeCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + o, err := c.Globals.APIClient.GetServiceAuthorization(&c.Input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service Authorization ID": c.Input.ID, + }) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + return c.print(o, out) +} + +func (c *DescribeCommand) print(s *fastly.ServiceAuthorization, out io.Writer) error { + fmt.Fprintf(out, "Auth ID: %s\n", s.ID) + fmt.Fprintf(out, "User ID: %s\n", s.User.ID) + fmt.Fprintf(out, "Service ID: %s\n", s.Service.ID) + fmt.Fprintf(out, "Permission: %s\n", s.Permission) + + if s.CreatedAt != nil { + fmt.Fprintf(out, "Created (UTC): %s\n", s.CreatedAt.UTC().Format(time.Format)) + } + if s.UpdatedAt != nil { + fmt.Fprintf(out, "Last edited (UTC): %s\n", s.UpdatedAt.UTC().Format(time.Format)) + } + if s.DeletedAt != nil { + fmt.Fprintf(out, "Deleted (UTC): %s\n", s.DeletedAt.UTC().Format(time.Format)) + } + + return nil +} diff --git a/pkg/commands/serviceauth/doc.go b/pkg/commands/serviceauth/doc.go new file mode 100644 index 000000000..022285229 --- /dev/null +++ b/pkg/commands/serviceauth/doc.go @@ -0,0 +1,3 @@ +// Package serviceauth contains commands to inspect and manipulate authorization +// to Fastly services. +package serviceauth diff --git a/pkg/commands/serviceauth/list.go b/pkg/commands/serviceauth/list.go new file mode 100644 index 000000000..52799b96d --- /dev/null +++ b/pkg/commands/serviceauth/list.go @@ -0,0 +1,87 @@ +package serviceauth + +import ( + "fmt" + "io" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" + "github.com/fastly/cli/pkg/time" + "github.com/fastly/go-fastly/v10/fastly" +) + +// ListCommand calls the Fastly API to list service authorizations. +type ListCommand struct { + argparser.Base + argparser.JSONOutput + + input fastly.ListServiceAuthorizationsInput +} + +// NewListCommand returns a usable command registered under the parent. +func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { + var c ListCommand + c.Globals = g + c.CmdClause = parent.Command("list", "List service authorizations") + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + c.CmdClause.Flag("page", "Page number of data set to fetch").IntVar(&c.input.PageNumber) + c.CmdClause.Flag("per-page", "Number of records per page").IntVar(&c.input.PageSize) + return &c +} + +// Exec invokes the application logic for the command. +func (c *ListCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + o, err := c.Globals.APIClient.ListServiceAuthorizations(&c.input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Page Number": c.input.PageNumber, + "Page Size": c.input.PageSize, + }) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + if !c.Globals.Verbose() { + if len(o.Items) > 0 { + tw := text.NewTable(out) + tw.AddHeader("AUTH ID", "USER ID", "SERVICE ID", "PERMISSION") + + for _, s := range o.Items { + tw.AddLine(s.ID, s.User.ID, s.Service.ID, s.Permission) + } + tw.Print() + + return nil + } + } + + for _, s := range o.Items { + fmt.Fprintf(out, "Auth ID: %s\n", s.ID) + fmt.Fprintf(out, "User ID: %s\n", s.User.ID) + fmt.Fprintf(out, "Service ID: %s\n", s.Service.ID) + fmt.Fprintf(out, "Permission: %s\n", s.Permission) + + if s.CreatedAt != nil { + fmt.Fprintf(out, "Created (UTC): %s\n", s.CreatedAt.UTC().Format(time.Format)) + } + if s.UpdatedAt != nil { + fmt.Fprintf(out, "Last edited (UTC): %s\n", s.UpdatedAt.UTC().Format(time.Format)) + } + if s.DeletedAt != nil { + fmt.Fprintf(out, "Deleted (UTC): %s\n", s.DeletedAt.UTC().Format(time.Format)) + } + } + + return nil +} diff --git a/pkg/commands/serviceauth/root.go b/pkg/commands/serviceauth/root.go new file mode 100644 index 000000000..515f0d0eb --- /dev/null +++ b/pkg/commands/serviceauth/root.go @@ -0,0 +1,31 @@ +package serviceauth + +import ( + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" +) + +// RootCommand is the parent command for all subcommands in this package. +// It should be installed under the primary root command. +type RootCommand struct { + argparser.Base + // no flags +} + +// CommandName is the string to be used to invoke this command. +const CommandName = "service-auth" + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent argparser.Registerer, g *global.Data) *RootCommand { + var c RootCommand + c.Globals = g + c.CmdClause = parent.Command(CommandName, "Allow users to access only specified services") + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { + panic("unreachable") +} diff --git a/pkg/commands/serviceauth/service_test.go b/pkg/commands/serviceauth/service_test.go new file mode 100644 index 000000000..0c9c29e47 --- /dev/null +++ b/pkg/commands/serviceauth/service_test.go @@ -0,0 +1,336 @@ +package serviceauth_test + +import ( + "bytes" + "errors" + "io" + "strings" + "testing" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/app" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" +) + +func TestServiceAuthCreate(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("service-auth create"), + wantError: "error parsing arguments: required flag --user-id not provided", + }, + { + args: args("service-auth create --user-id 123 --service-id 123"), + api: mock.API{CreateServiceAuthorizationFn: createServiceAuthError}, + wantError: errTest.Error(), + }, + { + args: args("service-auth create --user-id 123 --service-id 123"), + api: mock.API{CreateServiceAuthorizationFn: createServiceAuthOK}, + wantOutput: "Created service authorization 12345", + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertStringContains(t, stdout.String(), testcase.wantOutput) + }) + } +} + +func TestServiceAuthList(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("service-auth list --verbose --json"), + wantError: "invalid flag combination, --verbose and --json", + }, + { + args: args("service-auth list"), + api: mock.API{ListServiceAuthorizationsFn: listServiceAuthError}, + wantError: errTest.Error(), + }, + { + args: args("service-auth list"), + api: mock.API{ListServiceAuthorizationsFn: listServiceAuthOK}, + wantOutput: "AUTH ID USER ID SERVICE ID PERMISSION\n123 456 789 read_only\n", + }, + { + args: args("service-auth list --json"), + api: mock.API{ListServiceAuthorizationsFn: listServiceAuthOK}, + wantOutput: `{ + "Info": { + "links": {}, + "meta": {} + }, + "Items": [ + { + "CreatedAt": null, + "DeletedAt": null, + "ID": "123", + "Permission": "read_only", + "Service": { + "ID": "789" + }, + "UpdatedAt": null, + "User": { + "ID": "456" + } + } + ] +}`, + }, + { + args: args("service-auth list --verbose"), + api: mock.API{ListServiceAuthorizationsFn: listServiceAuthOK}, + wantOutput: "Fastly API endpoint: https://api.fastly.com\nFastly API token provided via config file (profile: user)\n\nAuth ID: 123\nUser ID: 456\nService ID: 789\nPermission: read_only\n", + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + t.Log(stdout.String()) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertStringContains(t, stdout.String(), testcase.wantOutput) + }) + } +} + +func TestServiceAuthDescribe(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("service-auth describe"), + wantError: "error parsing arguments: required flag --id not provided", + }, + { + args: args("service-auth describe --id 123 --verbose --json"), + wantError: "invalid flag combination, --verbose and --json", + }, + { + args: args("service-auth describe --id 123"), + api: mock.API{GetServiceAuthorizationFn: describeServiceAuthError}, + wantError: errTest.Error(), + }, + { + args: args("service-auth describe --id 123"), + api: mock.API{GetServiceAuthorizationFn: describeServiceAuthOK}, + wantOutput: "Auth ID: 12345\nUser ID: 456\nService ID: 789\nPermission: read_only\n", + }, + { + args: args("service-auth describe --id 123 --json"), + api: mock.API{GetServiceAuthorizationFn: describeServiceAuthOK}, + wantOutput: `{ + "CreatedAt": null, + "DeletedAt": null, + "ID": "12345", + "Permission": "read_only", + "Service": { + "ID": "789" + }, + "UpdatedAt": null, + "User": { + "ID": "456" + } +}`, + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + t.Log(stdout.String()) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertStringContains(t, stdout.String(), testcase.wantOutput) + }) + } +} + +func TestServiceAuthUpdate(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("service-auth update --permission full"), + wantError: "error parsing arguments: required flag --id not provided", + }, + { + args: args("service-auth update --id 123"), + wantError: "error parsing arguments: required flag --permission not provided", + }, + { + args: args("service-auth update --id 123 --permission full"), + api: mock.API{UpdateServiceAuthorizationFn: updateServiceAuthError}, + wantError: errTest.Error(), + }, + { + args: args("service-auth update --id 123 --permission full"), + api: mock.API{UpdateServiceAuthorizationFn: updateServiceAuthOK}, + wantOutput: "Updated service authorization 123", + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertStringContains(t, stdout.String(), testcase.wantOutput) + }) + } +} + +func TestServiceAuthDelete(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("service-auth delete"), + wantError: "error parsing arguments: required flag --id not provided", + }, + { + args: args("service-auth delete --id 123"), + api: mock.API{DeleteServiceAuthorizationFn: deleteServiceAuthError}, + wantError: errTest.Error(), + }, + { + args: args("service-auth delete --id 123"), + api: mock.API{DeleteServiceAuthorizationFn: deleteServiceAuthOK}, + wantOutput: "Deleted service authorization 123", + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertStringContains(t, stdout.String(), testcase.wantOutput) + }) + } +} + +var errTest = errors.New("fixture error") + +func createServiceAuthError(*fastly.CreateServiceAuthorizationInput) (*fastly.ServiceAuthorization, error) { + return nil, errTest +} + +func createServiceAuthOK(_ *fastly.CreateServiceAuthorizationInput) (*fastly.ServiceAuthorization, error) { + return &fastly.ServiceAuthorization{ + ID: "12345", + }, nil +} + +func listServiceAuthError(*fastly.ListServiceAuthorizationsInput) (*fastly.ServiceAuthorizations, error) { + return nil, errTest +} + +func listServiceAuthOK(_ *fastly.ListServiceAuthorizationsInput) (*fastly.ServiceAuthorizations, error) { + return &fastly.ServiceAuthorizations{ + Items: []*fastly.ServiceAuthorization{ + { + ID: "123", + User: &fastly.SAUser{ + ID: "456", + }, + Service: &fastly.SAService{ + ID: "789", + }, + Permission: "read_only", + }, + }, + }, nil +} + +func describeServiceAuthError(*fastly.GetServiceAuthorizationInput) (*fastly.ServiceAuthorization, error) { + return nil, errTest +} + +func describeServiceAuthOK(_ *fastly.GetServiceAuthorizationInput) (*fastly.ServiceAuthorization, error) { + return &fastly.ServiceAuthorization{ + ID: "12345", + User: &fastly.SAUser{ + ID: "456", + }, + Service: &fastly.SAService{ + ID: "789", + }, + Permission: "read_only", + }, nil +} + +func updateServiceAuthError(*fastly.UpdateServiceAuthorizationInput) (*fastly.ServiceAuthorization, error) { + return nil, errTest +} + +func updateServiceAuthOK(_ *fastly.UpdateServiceAuthorizationInput) (*fastly.ServiceAuthorization, error) { + return &fastly.ServiceAuthorization{ + ID: "12345", + }, nil +} + +func deleteServiceAuthError(*fastly.DeleteServiceAuthorizationInput) error { + return errTest +} + +func deleteServiceAuthOK(_ *fastly.DeleteServiceAuthorizationInput) error { + return nil +} diff --git a/pkg/commands/serviceauth/testdata/fastly-no-serviceid.toml b/pkg/commands/serviceauth/testdata/fastly-no-serviceid.toml new file mode 100644 index 000000000..99b315130 --- /dev/null +++ b/pkg/commands/serviceauth/testdata/fastly-no-serviceid.toml @@ -0,0 +1,5 @@ +manifest_version = 2 +name = "Default Rust template" +description = "Default package template for Rust based edge compute projects." +authors = ["phamann "] +language = "rust" diff --git a/pkg/commands/serviceauth/testdata/fastly-valid.toml b/pkg/commands/serviceauth/testdata/fastly-valid.toml new file mode 100644 index 000000000..1411c1dde --- /dev/null +++ b/pkg/commands/serviceauth/testdata/fastly-valid.toml @@ -0,0 +1,6 @@ +manifest_version = 2 +name = "Default Rust template" +description = "Default package template for Rust based edge compute projects." +authors = ["phamann "] +language = "rust" +service_id = "123" diff --git a/pkg/commands/serviceauth/update.go b/pkg/commands/serviceauth/update.go new file mode 100644 index 000000000..f81e17d7d --- /dev/null +++ b/pkg/commands/serviceauth/update.go @@ -0,0 +1,47 @@ +package serviceauth + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// UpdateCommand calls the Fastly API to update service authorizations. +type UpdateCommand struct { + argparser.Base + + input fastly.UpdateServiceAuthorizationInput +} + +// NewUpdateCommand returns a usable command registered under the parent. +func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { + c := UpdateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("update", "Update service authorization") + + // Required. + c.CmdClause.Flag("id", "ID of the service authorization to delete").Required().StringVar(&c.input.ID) + c.CmdClause.Flag("permission", "The permission the user has in relation to the service").Required().HintOptions(Permissions...).Short('p').EnumVar(&c.input.Permission, Permissions...) + return &c +} + +// Exec invokes the application logic for the command. +func (c *UpdateCommand) Exec(_ io.Reader, out io.Writer) error { + s, err := c.Globals.APIClient.UpdateServiceAuthorization(&c.input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service Authorization ID": c.input.ID, + }) + return err + } + + text.Success(out, "Updated service authorization %s", s.ID) + return nil +} diff --git a/pkg/commands/serviceversion/activate.go b/pkg/commands/serviceversion/activate.go new file mode 100644 index 000000000..ecd735200 --- /dev/null +++ b/pkg/commands/serviceversion/activate.go @@ -0,0 +1,89 @@ +package serviceversion + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// ActivateCommand calls the Fastly API to activate a service version. +type ActivateCommand struct { + argparser.Base + Input fastly.ActivateVersionInput + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion + autoClone argparser.OptionalAutoClone +} + +// NewActivateCommand returns a usable command registered under the parent. +func NewActivateCommand(parent argparser.Registerer, g *global.Data) *ActivateCommand { + var c ActivateCommand + c.Globals = g + c.CmdClause = parent.Command("activate", "Activate a Fastly service version") + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.autoClone.Set, + Dst: &c.autoClone.Value, + }) + return &c +} + +// Exec invokes the application logic for the command. +func (c *ActivateCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + AutoCloneFlag: c.autoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + c.Input.ServiceID = serviceID + c.Input.ServiceVersion = fastly.ToValue(serviceVersion.Number) + + ver, err := c.Globals.APIClient.ActivateVersion(&c.Input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": serviceVersion.Number, + }) + return err + } + + text.Success(out, "Activated service %s version %d", fastly.ToValue(ver.ServiceID), c.Input.ServiceVersion) + return nil +} diff --git a/pkg/commands/serviceversion/clone.go b/pkg/commands/serviceversion/clone.go new file mode 100644 index 000000000..0c55d7ab4 --- /dev/null +++ b/pkg/commands/serviceversion/clone.go @@ -0,0 +1,80 @@ +package serviceversion + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// CloneCommand calls the Fastly API to clone a service version. +type CloneCommand struct { + argparser.Base + Input fastly.CloneVersionInput + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion +} + +// NewCloneCommand returns a usable command registered under the parent. +func NewCloneCommand(parent argparser.Registerer, g *global.Data) *CloneCommand { + var c CloneCommand + c.Globals = g + c.CmdClause = parent.Command("clone", "Clone a Fastly service version") + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + return &c +} + +// Exec invokes the application logic for the command. +func (c *CloneCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + c.Input.ServiceID = serviceID + c.Input.ServiceVersion = fastly.ToValue(serviceVersion.Number) + + ver, err := c.Globals.APIClient.CloneVersion(&c.Input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fastly.ToValue(serviceVersion.Number), + }) + return err + } + + text.Success(out, "Cloned service %s version %d to version %d", fastly.ToValue(ver.ServiceID), c.Input.ServiceVersion, fastly.ToValue(ver.Number)) + return nil +} diff --git a/pkg/commands/serviceversion/deactivate.go b/pkg/commands/serviceversion/deactivate.go new file mode 100644 index 000000000..a4e657bbc --- /dev/null +++ b/pkg/commands/serviceversion/deactivate.go @@ -0,0 +1,83 @@ +package serviceversion + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// DeactivateCommand calls the Fastly API to deactivate a service version. +type DeactivateCommand struct { + argparser.Base + Input fastly.DeactivateVersionInput + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion +} + +// NewDeactivateCommand returns a usable command registered under the parent. +func NewDeactivateCommand(parent argparser.Registerer, g *global.Data) *DeactivateCommand { + var c DeactivateCommand + c.Globals = g + c.CmdClause = parent.Command("deactivate", "Deactivate a Fastly service version") + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + return &c +} + +// Exec invokes the application logic for the command. +func (c *DeactivateCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(true), + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + c.Input.ServiceID = serviceID + c.Input.ServiceVersion = fastly.ToValue(serviceVersion.Number) + + ver, err := c.Globals.APIClient.DeactivateVersion(&c.Input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fastly.ToValue(serviceVersion.Number), + }) + return err + } + + text.Success(out, "Deactivated service %s version %d", fastly.ToValue(ver.ServiceID), c.Input.ServiceVersion) + return nil +} diff --git a/pkg/serviceversion/doc.go b/pkg/commands/serviceversion/doc.go similarity index 100% rename from pkg/serviceversion/doc.go rename to pkg/commands/serviceversion/doc.go diff --git a/pkg/commands/serviceversion/list.go b/pkg/commands/serviceversion/list.go new file mode 100644 index 000000000..06bc10bdd --- /dev/null +++ b/pkg/commands/serviceversion/list.go @@ -0,0 +1,108 @@ +package serviceversion + +import ( + "fmt" + "io" + "time" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" + fsttime "github.com/fastly/cli/pkg/time" +) + +// ListCommand calls the Fastly API to list services. +type ListCommand struct { + argparser.Base + argparser.JSONOutput + + Input fastly.ListVersionsInput + serviceName argparser.OptionalServiceNameID +} + +// NewListCommand returns a usable command registered under the parent. +func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { + c := ListCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("list", "List Fastly service versions") + c.RegisterFlagBool(c.JSONFlag()) // --json + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + return &c +} + +// Exec invokes the application logic for the command. +func (c *ListCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + serviceID, source, flag, err := argparser.ServiceID(c.serviceName, *c.Globals.Manifest, c.Globals.APIClient, c.Globals.ErrLog) + if err != nil { + return err + } + if c.Globals.Verbose() { + argparser.DisplayServiceID(serviceID, flag, source, out) + } + + c.Input.ServiceID = serviceID + + o, err := c.Globals.APIClient.ListVersions(&c.Input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + }) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + if !c.Globals.Verbose() { + tw := text.NewTable(out) + tw.AddHeader("NUMBER", "ACTIVE", "STAGED", "LAST EDITED (UTC)") + for _, version := range o { + tw.AddLine( + fastly.ToValue(version.Number), + fastly.ToValue(version.Active), + fastly.ToValue(version.Staging), + parseTime(version.UpdatedAt), + ) + } + tw.Print() + return nil + } + + fmt.Fprintf(out, "Versions: %d\n", len(o)) + for i, version := range o { + fmt.Fprintf(out, "\tVersion %d/%d\n", i+1, len(o)) + text.PrintVersion(out, "\t\t", version) + } + fmt.Fprintln(out) + + return nil +} + +func parseTime(ua *time.Time) string { + if ua == nil { + return "" + } + return ua.UTC().Format(fsttime.Format) +} diff --git a/pkg/commands/serviceversion/lock.go b/pkg/commands/serviceversion/lock.go new file mode 100644 index 000000000..491e1a643 --- /dev/null +++ b/pkg/commands/serviceversion/lock.go @@ -0,0 +1,83 @@ +package serviceversion + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// LockCommand calls the Fastly API to lock a service version. +type LockCommand struct { + argparser.Base + Input fastly.LockVersionInput + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion +} + +// NewLockCommand returns a usable command registered under the parent. +func NewLockCommand(parent argparser.Registerer, g *global.Data) *LockCommand { + var c LockCommand + c.Globals = g + c.CmdClause = parent.Command("lock", "Lock a Fastly service version") + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + return &c +} + +// Exec invokes the application logic for the command. +func (c *LockCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Locked: optional.Of(false), + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + c.Input.ServiceID = serviceID + c.Input.ServiceVersion = fastly.ToValue(serviceVersion.Number) + + ver, err := c.Globals.APIClient.LockVersion(&c.Input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fastly.ToValue(serviceVersion.Number), + }) + return err + } + + text.Success(out, "Locked service %s version %d", fastly.ToValue(ver.ServiceID), c.Input.ServiceVersion) + return nil +} diff --git a/pkg/commands/serviceversion/root.go b/pkg/commands/serviceversion/root.go new file mode 100644 index 000000000..551f1c4f6 --- /dev/null +++ b/pkg/commands/serviceversion/root.go @@ -0,0 +1,31 @@ +package serviceversion + +import ( + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" +) + +// RootCommand is the parent command for all subcommands in this package. +// It should be installed under the primary root command. +type RootCommand struct { + argparser.Base + // no flags +} + +// CommandName is the string to be used to invoke this command. +const CommandName = "service-version" + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent argparser.Registerer, g *global.Data) *RootCommand { + var c RootCommand + c.Globals = g + c.CmdClause = parent.Command(CommandName, "Manipulate Fastly service versions") + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { + panic("unreachable") +} diff --git a/pkg/commands/serviceversion/serviceversion_test.go b/pkg/commands/serviceversion/serviceversion_test.go new file mode 100644 index 000000000..42be1af46 --- /dev/null +++ b/pkg/commands/serviceversion/serviceversion_test.go @@ -0,0 +1,455 @@ +package serviceversion_test + +import ( + "strings" + "testing" + + "github.com/fastly/go-fastly/v10/fastly" + + root "github.com/fastly/cli/pkg/commands/serviceversion" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" +) + +func TestVersionClone(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --service-id flag", + Args: "--version 1", + WantError: "error reading service: no service ID found", + }, + { + Name: "validate missing --version flag", + Args: "--service-id 123", + WantError: "error parsing arguments: required flag --version not provided", + }, + { + Name: "validate successful clone", + Args: "--service-id 123 --version 1", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + }, + WantOutput: "Cloned service 123 version 1 to version 4", + }, + { + Name: "validate error will be passed through if cloning fails", + Args: "--service-id 456 --version 1", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionError, + }, + WantError: testutil.Err.Error(), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, "clone"}, scenarios) +} + +func TestVersionList(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Args: "--service-id 123", + API: mock.API{ListVersionsFn: testutil.ListVersions}, + WantOutput: listVersionsShortOutput, + }, + { + Args: "--service-id 123 --verbose", + API: mock.API{ListVersionsFn: testutil.ListVersions}, + WantOutput: listVersionsVerboseOutput, + }, + { + Args: "--service-id 123 -v", + API: mock.API{ListVersionsFn: testutil.ListVersions}, + WantOutput: listVersionsVerboseOutput, + }, + { + Args: "--verbose --service-id 123", + API: mock.API{ListVersionsFn: testutil.ListVersions}, + WantOutput: listVersionsVerboseOutput, + }, + { + Args: "-v --service-id 123", + API: mock.API{ListVersionsFn: testutil.ListVersions}, + WantOutput: listVersionsVerboseOutput, + }, + { + Args: "--service-id 123", + API: mock.API{ListVersionsFn: testutil.ListVersionsError}, + WantError: testutil.Err.Error(), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, "list"}, scenarios) +} + +func TestVersionUpdate(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Args: "--service-id 123 --version 1 --comment foo --autoclone", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + UpdateVersionFn: updateVersionOK, + }, + WantOutput: "Updated service 123 version 4", + }, + { + Args: "--service-id 123 --version 1 --autoclone", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + }, + WantError: "error parsing arguments: required flag --comment not provided", + }, + { + Args: "--service-id 123 --version 1 --comment foo --autoclone", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + UpdateVersionFn: updateVersionError, + }, + WantError: testutil.Err.Error(), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, "update"}, scenarios) +} + +func TestVersionActivate(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Args: "--service-id 123", + WantError: "error parsing arguments: required flag --version not provided", + }, + { + Args: "--service-id 123 --version 1", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + }, + WantError: "service version 1 is active", + }, + { + Args: "--service-id 123 --version 1 --autoclone", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + ActivateVersionFn: activateVersionError, + }, + WantError: testutil.Err.Error(), + }, + { + Args: "--service-id 123 --version 1 --autoclone", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + ActivateVersionFn: activateVersionOK, + }, + WantOutput: "Activated service 123 version 4", + }, + { + Args: "--service-id 123 --version 2 --autoclone", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + ActivateVersionFn: activateVersionOK, + }, + WantOutput: "Activated service 123 version 4", + }, + { + Args: "--service-id 123 --version 3 --autoclone", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + ActivateVersionFn: activateVersionOK, + }, + WantOutput: "Activated service 123 version 3", + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, "activate"}, scenarios) +} + +func TestVersionDeactivate(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Args: "--service-id 123", + WantError: "error parsing arguments: required flag --version not provided", + }, + { + Args: "--service-id 123 --version 1", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + DeactivateVersionFn: deactivateVersionOK, + }, + WantOutput: "Deactivated service 123 version 1", + }, + { + Args: "--service-id 123 --version 3", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + DeactivateVersionFn: deactivateVersionOK, + }, + WantError: "service version 3 is not active", + }, + { + Args: "--service-id 123 --version 1", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + DeactivateVersionFn: deactivateVersionError, + }, + WantError: testutil.Err.Error(), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, "deactivate"}, scenarios) +} + +func TestVersionLock(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Args: "--service-id 123", + WantError: "error parsing arguments: required flag --version not provided", + }, + { + Args: "--service-id 123 --version 1", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + LockVersionFn: lockVersionOK, + }, + WantOutput: "Locked service 123 version 1", + }, + { + Args: "--service-id 123 --version 1", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + LockVersionFn: lockVersionError, + }, + WantError: testutil.Err.Error(), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, "lock"}, scenarios) +} + +func TestVersionStage(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Args: "--service-id 123", + WantError: "error parsing arguments: required flag --version not provided", + }, + { + Args: "--service-id 123 --version 1", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + ActivateVersionFn: stageVersionOK, + }, + WantError: "service version 1 is active", + }, + { + Args: "--service-id 123 --version 2", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + ActivateVersionFn: stageVersionOK, + }, + WantError: "service version 2 is locked", + }, + { + Args: "--service-id 123 --version 3", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + ActivateVersionFn: stageVersionError, + }, + WantError: testutil.Err.Error(), + }, + { + Args: "--service-id 123 --version 3", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + ActivateVersionFn: stageVersionOK, + }, + WantOutput: "Staged service 123 version 3", + }, + { + Args: "--service-id 123 --version 4", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + ActivateVersionFn: stageVersionOK, + }, + WantOutput: "Staged service 123 version 4", + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, "stage"}, scenarios) +} + +func TestVersionUnstage(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Args: "--service-id 123", + WantError: "error parsing arguments: required flag --version not provided", + }, + { + Args: "--service-id 123 --version 1", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + DeactivateVersionFn: unstageVersionOK, + }, + WantError: "service version 1 is not staged", + }, + { + Args: "--service-id 123 --version 3", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + DeactivateVersionFn: unstageVersionError, + }, + WantError: "service version 3 is not staged", + }, + { + Args: "--service-id 123 --version 4", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + DeactivateVersionFn: unstageVersionError, + }, + WantError: testutil.Err.Error(), + }, + { + Args: "--service-id 123 --version 4", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + DeactivateVersionFn: unstageVersionOK, + }, + WantOutput: "Unstaged service 123 version 4", + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, "unstage"}, scenarios) +} + +var listVersionsShortOutput = strings.TrimSpace(` +NUMBER ACTIVE STAGED LAST EDITED (UTC) +1 true false 2000-01-01 01:00 +2 false false 2000-01-02 01:00 +3 false false 2000-01-03 01:00 +4 false true 2000-01-04 01:00 +`) + "\n" + +var listVersionsVerboseOutput = strings.TrimSpace(` +Fastly API endpoint: https://api.fastly.com +Fastly API token provided via config file (profile: user) + +Service ID (via --service-id): 123 + +Versions: 4 + Version 1/4 + Number: 1 + Service ID: 123 + Active: true + Last edited (UTC): 2000-01-01 01:00 + Version 2/4 + Number: 2 + Service ID: 123 + Locked: true + Last edited (UTC): 2000-01-02 01:00 + Version 3/4 + Number: 3 + Service ID: 123 + Last edited (UTC): 2000-01-03 01:00 + Version 4/4 + Number: 4 + Service ID: 123 + Staged: true + Last edited (UTC): 2000-01-04 01:00 +`) + "\n\n" + +func updateVersionOK(i *fastly.UpdateVersionInput) (*fastly.Version, error) { + return &fastly.Version{ + Number: fastly.ToPointer(i.ServiceVersion), + ServiceID: fastly.ToPointer("123"), + Active: fastly.ToPointer(true), + Deployed: fastly.ToPointer(true), + Comment: fastly.ToPointer("foo"), + CreatedAt: testutil.MustParseTimeRFC3339("2010-11-15T19:01:02Z"), + }, nil +} + +func updateVersionError(_ *fastly.UpdateVersionInput) (*fastly.Version, error) { + return nil, testutil.Err +} + +func activateVersionOK(i *fastly.ActivateVersionInput) (*fastly.Version, error) { + return &fastly.Version{ + Number: fastly.ToPointer(i.ServiceVersion), + ServiceID: fastly.ToPointer("123"), + Active: fastly.ToPointer(true), + Deployed: fastly.ToPointer(true), + CreatedAt: testutil.MustParseTimeRFC3339("2010-11-15T19:01:02Z"), + UpdatedAt: testutil.MustParseTimeRFC3339("2010-11-15T19:01:02Z"), + }, nil +} + +func activateVersionError(_ *fastly.ActivateVersionInput) (*fastly.Version, error) { + return nil, testutil.Err +} + +func deactivateVersionOK(i *fastly.DeactivateVersionInput) (*fastly.Version, error) { + return &fastly.Version{ + Number: fastly.ToPointer(i.ServiceVersion), + ServiceID: fastly.ToPointer("123"), + Active: fastly.ToPointer(false), + Deployed: fastly.ToPointer(true), + CreatedAt: testutil.MustParseTimeRFC3339("2010-11-15T19:01:02Z"), + UpdatedAt: testutil.MustParseTimeRFC3339("2010-11-15T19:01:02Z"), + }, nil +} + +func deactivateVersionError(_ *fastly.DeactivateVersionInput) (*fastly.Version, error) { + return nil, testutil.Err +} + +func stageVersionOK(i *fastly.ActivateVersionInput) (*fastly.Version, error) { + return &fastly.Version{ + Number: fastly.ToPointer(i.ServiceVersion), + ServiceID: fastly.ToPointer("123"), + Active: fastly.ToPointer(true), + Deployed: fastly.ToPointer(true), + Staging: fastly.ToPointer(true), + CreatedAt: testutil.MustParseTimeRFC3339("2010-11-15T19:01:02Z"), + UpdatedAt: testutil.MustParseTimeRFC3339("2010-11-15T19:01:02Z"), + }, nil +} + +func stageVersionError(_ *fastly.ActivateVersionInput) (*fastly.Version, error) { + return nil, testutil.Err +} + +func unstageVersionOK(i *fastly.DeactivateVersionInput) (*fastly.Version, error) { + return &fastly.Version{ + Number: fastly.ToPointer(i.ServiceVersion), + ServiceID: fastly.ToPointer("123"), + Active: fastly.ToPointer(false), + Deployed: fastly.ToPointer(true), + Staging: fastly.ToPointer(false), + CreatedAt: testutil.MustParseTimeRFC3339("2010-11-15T19:01:02Z"), + UpdatedAt: testutil.MustParseTimeRFC3339("2010-11-15T19:01:02Z"), + }, nil +} + +func unstageVersionError(_ *fastly.DeactivateVersionInput) (*fastly.Version, error) { + return nil, testutil.Err +} + +func lockVersionOK(i *fastly.LockVersionInput) (*fastly.Version, error) { + return &fastly.Version{ + Number: fastly.ToPointer(i.ServiceVersion), + ServiceID: fastly.ToPointer("123"), + Active: fastly.ToPointer(false), + Deployed: fastly.ToPointer(true), + Locked: fastly.ToPointer(true), + CreatedAt: testutil.MustParseTimeRFC3339("2010-11-15T19:01:02Z"), + UpdatedAt: testutil.MustParseTimeRFC3339("2010-11-15T19:01:02Z"), + }, nil +} + +func lockVersionError(_ *fastly.LockVersionInput) (*fastly.Version, error) { + return nil, testutil.Err +} diff --git a/pkg/commands/serviceversion/stage.go b/pkg/commands/serviceversion/stage.go new file mode 100644 index 000000000..5b3a8b0a3 --- /dev/null +++ b/pkg/commands/serviceversion/stage.go @@ -0,0 +1,86 @@ +package serviceversion + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// StageCommand calls the Fastly API to stage a service version. +type StageCommand struct { + argparser.Base + Input fastly.ActivateVersionInput + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion +} + +// NewStageCommand returns a usable command registered under the parent. +func NewStageCommand(parent argparser.Registerer, g *global.Data) *StageCommand { + var c StageCommand + c.Globals = g + // FIXME: unhide this command when appropriate + c.CmdClause = parent.Command("stage", "Stage a Fastly service version").Hidden() + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + return &c +} + +// Exec invokes the application logic for the command. +func (c *StageCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + c.Input.ServiceID = serviceID + c.Input.ServiceVersion = fastly.ToValue(serviceVersion.Number) + c.Input.Environment = "staging" + + ver, err := c.Globals.APIClient.ActivateVersion(&c.Input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": serviceVersion.Number, + }) + return err + } + + text.Success(out, "Staged service %s version %d", fastly.ToValue(ver.ServiceID), c.Input.ServiceVersion) + return nil +} diff --git a/pkg/commands/serviceversion/unstage.go b/pkg/commands/serviceversion/unstage.go new file mode 100644 index 000000000..efc116e9f --- /dev/null +++ b/pkg/commands/serviceversion/unstage.go @@ -0,0 +1,85 @@ +package serviceversion + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// UnstageCommand calls the Fastly API to unstage a service version. +type UnstageCommand struct { + argparser.Base + Input fastly.DeactivateVersionInput + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion +} + +// NewUnstageCommand returns a usable command registered under the parent. +func NewUnstageCommand(parent argparser.Registerer, g *global.Data) *UnstageCommand { + var c UnstageCommand + c.Globals = g + // FIXME: unhide this command when appropriate + c.CmdClause = parent.Command("unstage", "Unstage a Fastly service version").Hidden() + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + return &c +} + +// Exec invokes the application logic for the command. +func (c *UnstageCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Staging: optional.Of(true), + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + c.Input.ServiceID = serviceID + c.Input.ServiceVersion = fastly.ToValue(serviceVersion.Number) + c.Input.Environment = "staging" + + ver, err := c.Globals.APIClient.DeactivateVersion(&c.Input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fastly.ToValue(serviceVersion.Number), + }) + return err + } + + text.Success(out, "Unstaged service %s version %d", fastly.ToValue(ver.ServiceID), c.Input.ServiceVersion) + return nil +} diff --git a/pkg/commands/serviceversion/update.go b/pkg/commands/serviceversion/update.go new file mode 100644 index 000000000..30b62fd69 --- /dev/null +++ b/pkg/commands/serviceversion/update.go @@ -0,0 +1,104 @@ +package serviceversion + +import ( + "fmt" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// UpdateCommand calls the Fastly API to update a service version. +type UpdateCommand struct { + argparser.Base + input fastly.UpdateVersionInput + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion + autoClone argparser.OptionalAutoClone + + comment argparser.OptionalString +} + +// NewUpdateCommand returns a usable command registered under the parent. +func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { + c := UpdateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("update", "Update a Fastly service version") + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.autoClone.Set, + Dst: &c.autoClone.Value, + }) + + // TODO(integralist): + // Make 'comment' field mandatory once we roll out a new release of Go-Fastly + // which will hopefully have better/more correct consistency as far as which + // fields are supposed to be optional and which should be 'required'. + // + c.CmdClause.Flag("comment", "Human-readable comment").Action(c.comment.Set).StringVar(&c.comment.Value) + return &c +} + +// Exec invokes the application logic for the command. +func (c *UpdateCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + AutoCloneFlag: c.autoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + c.input.ServiceID = serviceID + c.input.ServiceVersion = fastly.ToValue(serviceVersion.Number) + if !c.comment.WasSet { + return fmt.Errorf("error parsing arguments: required flag --comment not provided") + } + c.input.Comment = &c.comment.Value + + ver, err := c.Globals.APIClient.UpdateVersion(&c.input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fastly.ToValue(serviceVersion.Number), + "Comment": c.comment.Value, + }) + return err + } + + text.Success(out, "Updated service %s version %d", fastly.ToValue(ver.ServiceID), c.input.ServiceVersion) + return nil +} diff --git a/pkg/commands/shellcomplete/doc.go b/pkg/commands/shellcomplete/doc.go new file mode 100644 index 000000000..af28713c4 --- /dev/null +++ b/pkg/commands/shellcomplete/doc.go @@ -0,0 +1,3 @@ +// Package shellcomplete contains a hidden command used to prevent help output +// when --completion-script- is passed. +package shellcomplete diff --git a/pkg/commands/shellcomplete/root.go b/pkg/commands/shellcomplete/root.go new file mode 100644 index 000000000..16e549b25 --- /dev/null +++ b/pkg/commands/shellcomplete/root.go @@ -0,0 +1,31 @@ +package shellcomplete + +import ( + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" +) + +// RootCommand is the parent command for all subcommands in this package. +// It should be installed under the primary root command. +type RootCommand struct { + argparser.Base + // no flags +} + +// CommandName is the string to be used to invoke this command. +const CommandName = "shellcomplete" + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent argparser.Registerer, g *global.Data) *RootCommand { + var c RootCommand + c.Globals = g + c.CmdClause = parent.Command(CommandName, "Hidden command used to prevent help output when using --completion-script-").Hidden() + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { + panic("unreachable") +} diff --git a/pkg/commands/sso/doc.go b/pkg/commands/sso/doc.go new file mode 100644 index 000000000..14585f77e --- /dev/null +++ b/pkg/commands/sso/doc.go @@ -0,0 +1,3 @@ +// Package sso contains commands to authenticate with Fastly and to acquire a +// temporary API token, which will be auto-rotated using an access/refresh token. +package sso diff --git a/pkg/commands/sso/root.go b/pkg/commands/sso/root.go new file mode 100644 index 000000000..a7efba361 --- /dev/null +++ b/pkg/commands/sso/root.go @@ -0,0 +1,466 @@ +package sso + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strconv" + "time" + + "github.com/fastly/cli/pkg/api/undocumented" + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/auth" + "github.com/fastly/cli/pkg/config" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/profile" + "github.com/fastly/cli/pkg/text" + "github.com/fastly/cli/pkg/useragent" +) + +// ForceReAuth indicates we want to force a re-auth of the user's session. +// This variable is overridden by ../../app/run.go to force a re-auth. +var ForceReAuth = false + +// RootCommand is the parent command for all subcommands in this package. +// It should be installed under the primary root command. +type RootCommand struct { + argparser.Base + profile string + + // The following fields are populated once authentication is complete. + customerID string + customerName string + + // IMPORTANT: The following fields are public to the `profile` subcommands. + + // InvokedFromProfileCreate indicates if we should create a new profile. + InvokedFromProfileCreate bool + // ProfileCreateName indicates the new profile name. + ProfileCreateName string + // ProfileDefault indicates if the affected profile should become the default. + ProfileDefault bool + // InvokedFromProfileUpdate indicates if we should update a profile. + InvokedFromProfileUpdate bool + // ProfileUpdateName indicates the profile name to update. + ProfileUpdateName string + // InvokedFromProfileSwitch indicates if we should switch a profile. + InvokedFromProfileSwitch bool + // ProfileSwitchName indicates the profile name to switch to. + ProfileSwitchName string + // ProfileSwitchEmail indicates the profile email to reference in auth URL. + ProfileSwitchEmail string + // ProfileSwitchCustomerID indicates the customer ID to reference in auth URL. + ProfileSwitchCustomerID string + // InvokedFromSSO is an override for anyone using the `fastly sso` directly. + InvokedFromSSO bool +} + +// CommandName is the string to be used to invoke this command. +const CommandName = "sso" + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent argparser.Registerer, g *global.Data) *RootCommand { + var c RootCommand + c.Globals = g + // FIXME: Unhide this command once SSO is GA. + c.CmdClause = parent.Command(CommandName, "Single Sign-On authentication (defaults to current profile)") + c.CmdClause.Arg("profile", "Profile to authenticate (i.e. create/update a token for)").Short('p').StringVar(&c.profile) + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(in io.Reader, out io.Writer) error { + profileName, _ := c.identifyProfileAndFlow() + + // For creating/updating a profile we set `prompt` because we want to ensure + // that another session (from a different profile) doesn't cause unexpected + // errors for the user flow. This forces a re-auth. + switch { + case c.InvokedFromProfileCreate || ForceReAuth: + c.Globals.AuthServer.SetParam("prompt", "login select_account") + case c.InvokedFromProfileUpdate || c.InvokedFromProfileSwitch: + c.Globals.AuthServer.SetParam("prompt", "login") + if c.ProfileSwitchEmail != "" { + c.Globals.AuthServer.SetParam("login_hint", c.ProfileSwitchEmail) + } + if c.ProfileSwitchCustomerID != "" { + c.Globals.AuthServer.SetParam("account_hint", c.ProfileSwitchCustomerID) + } + default: + if c.profile != "" { + profileName = c.profile + } + // Handle `fastly sso` being invoked directly. + p := profile.Get(profileName, c.Globals.Config.Profiles) + if p == nil { + err := fmt.Errorf(profile.DoesNotExist, profileName) + c.Globals.ErrLog.Add(err) + return fsterr.RemediationError{ + Inner: err, + Remediation: fsterr.ProfileRemediation, + } + } + c.Globals.AuthServer.SetParam("prompt", "login") + c.Globals.AuthServer.SetParam("login_hint", p.Email) + c.Globals.AuthServer.SetParam("account_hint", p.CustomerID) + // IMPORTANT: We must make the specified profile the default. + // If we don't, then the next command run will fail to check the token. + // Because the current profile will not be the active session. + // And we don't want to have to force the user to have re-auth again. + // Really, `fastly sso` should be a hidden command. + // And all users should use the `fastly profile ...` subcommands. + c.ProfileDefault = true + c.InvokedFromSSO = true + } + + // We need to prompt the user, so they know we're about to open their web + // browser, but we also need to handle the scenario where the `sso` command is + // invoked indirectly via ../../app/run.go as that package will have its own + // (similar) prompt before invoking this command. So to avoid a double prompt, + // the app package will set `SkipAuthPrompt: true`. + if !c.Globals.SkipAuthPrompt && !c.Globals.Flags.AutoYes && !c.Globals.Flags.NonInteractive { + var defaultMsg string + if c.InvokedFromSSO { + defaultMsg = " and make it the default" + } + msg := fmt.Sprintf("We're going to authenticate the '%s' profile%s", profileName, defaultMsg) + text.Important(out, "%s. We need to open your browser to authenticate you.", msg) + text.Break(out) + cont, err := text.AskYesNo(out, text.BoldYellow("Do you want to continue? [y/N]: "), in) + text.Break(out) + if err != nil { + return err + } + if !cont { + return fsterr.SkipExitError{ + Skip: true, + Err: fsterr.ErrDontContinue, + } + } + } + + var serverErr error + go func() { + err := c.Globals.AuthServer.Start() + if err != nil { + serverErr = err + } + }() + if serverErr != nil { + return serverErr + } + + text.Info(out, "Starting a local server to handle the authentication flow.") + + authorizationURL, err := c.Globals.AuthServer.AuthURL() + if err != nil { + return fsterr.RemediationError{ + Inner: fmt.Errorf("failed to generate an authorization URL: %w", err), + Remediation: auth.Remediation, + } + } + + text.Break(out) + text.Description(out, "We're opening the following URL in your default web browser so you may authenticate with Fastly", authorizationURL) + + err = c.Globals.Opener(authorizationURL) + if err != nil { + return fmt.Errorf("failed to open your default browser: %w", err) + } + + ar := <-c.Globals.AuthServer.GetResult() + if ar.Err != nil || ar.SessionToken == "" { + err := ar.Err + if ar.Err == nil { + err = errors.New("no session token") + } + return fsterr.RemediationError{ + Inner: fmt.Errorf("failed to authorize: %w", err), + Remediation: auth.Remediation, + } + } + + err = c.processCustomer(ar) + if err != nil { + return fmt.Errorf("failed to use session token to get customer data: %w", err) + } + + err = c.processProfiles(ar) + if err != nil { + c.Globals.ErrLog.Add(err) + return fmt.Errorf("failed to process profile data: %w", err) + } + + textFn := text.Success + if c.InvokedFromProfileCreate || c.InvokedFromProfileUpdate || c.InvokedFromProfileSwitch { + textFn = text.Info + } + textFn(out, "Session token (persisted to your local configuration): %s", ar.SessionToken) + return nil +} + +// ProfileFlow enumerates which profile flow to take. +type ProfileFlow uint8 + +const ( + // ProfileNone indicates we need to create a new 'default' profile as no + // profiles currently exist. + ProfileNone ProfileFlow = iota + + // ProfileCreate indicates we need to create a new profile using details + // passed in either from the `sso` or `profile create` command. + ProfileCreate + + // ProfileUpdate indicates we need to update a profile using details passed in + // either from the `sso` or `profile update` command. + ProfileUpdate + + // ProfileSwitch indicates we need to re-authenticate and switch profiles. + // Triggered by user invoking `fastly profile switch` with an SSO-based profile. + ProfileSwitch +) + +// identifyProfileAndFlow identifies the profile and the specific workflow. +func (c *RootCommand) identifyProfileAndFlow() (profileName string, flow ProfileFlow) { + var profileOverride string + switch { + case c.Globals.Flags.Profile != "": + profileOverride = c.Globals.Flags.Profile + case c.Globals.Manifest.File.Profile != "": + profileOverride = c.Globals.Manifest.File.Profile + } + + currentDefaultProfile, _ := profile.Default(c.Globals.Config.Profiles) + var newDefaultProfile string + if currentDefaultProfile == "" && len(c.Globals.Config.Profiles) > 0 { + newDefaultProfile, c.Globals.Config.Profiles = profile.SetADefault(c.Globals.Config.Profiles) + } + + switch { + case profileOverride != "": + return profileOverride, ProfileUpdate + case c.profile != "" && profile.Get(c.profile, c.Globals.Config.Profiles) != nil: + return c.profile, ProfileUpdate + case c.InvokedFromProfileCreate && c.ProfileCreateName != "": + return c.ProfileCreateName, ProfileCreate + case c.InvokedFromProfileUpdate && c.ProfileUpdateName != "": + return c.ProfileUpdateName, ProfileUpdate + case c.InvokedFromProfileSwitch && c.ProfileSwitchName != "": + return c.ProfileSwitchName, ProfileSwitch + case currentDefaultProfile != "": + return currentDefaultProfile, ProfileUpdate + case newDefaultProfile != "": + return newDefaultProfile, ProfileUpdate + default: + return profile.DefaultName, ProfileCreate + } +} + +func (c *RootCommand) processCustomer(ar auth.AuthorizationResult) error { + debugMode, _ := strconv.ParseBool(c.Globals.Env.DebugMode) + apiEndpoint, _ := c.Globals.APIEndpoint() + // NOTE: The endpoint is documented but not implemented in go-fastly. + data, err := undocumented.Call(undocumented.CallOptions{ + APIEndpoint: apiEndpoint, + HTTPClient: c.Globals.HTTPClient, + HTTPHeaders: []undocumented.HTTPHeader{ + { + Key: "Accept", + Value: "application/json", + }, + { + Key: "User-Agent", + Value: useragent.Name, + }, + }, + Method: http.MethodGet, + Path: "/current_customer", + Token: ar.SessionToken, + Debug: debugMode, + }) + if err != nil { + c.Globals.ErrLog.Add(err) + return fmt.Errorf("error executing current_customer API request: %w", err) + } + + var response CurrentCustomerResponse + if err := json.Unmarshal(data, &response); err != nil { + c.Globals.ErrLog.Add(err) + return fmt.Errorf("error decoding current_customer API response: %w", err) + } + + c.customerID = response.ID + c.customerName = response.Name + + return nil +} + +// CurrentCustomerResponse models the Fastly API response for the +// /current_customer endpoint. +type CurrentCustomerResponse struct { + ID string `json:"id"` + Name string `json:"name"` +} + +// processProfiles updates the relevant profile with the returned token data. +// +// First it checks the --profile flag and the `profile` fastly.toml field. +// Second it checks to see which profile is currently the default. +// Third it identifies which profile to be modified. +// Fourth it writes the updated in-memory data back to disk. +func (c *RootCommand) processProfiles(ar auth.AuthorizationResult) error { + profileName, flow := c.identifyProfileAndFlow() + + //nolint:exhaustive + switch flow { + case ProfileCreate: + c.processCreateProfile(ar, profileName) + case ProfileUpdate: + // If a user calls `fastly sso` directly then they can be incorrectly + // identified as needing the ProfileUpdate flow. So we check for that and + // fallthrough to the ProfileSwitch otherwise. + if !c.InvokedFromSSO { + err := c.processUpdateProfile(ar, profileName) + if err != nil { + return fmt.Errorf("failed to update profile: %w", err) + } + } + fallthrough + case ProfileSwitch: + err := c.processSwitchProfile(ar, profileName) + if err != nil { + return fmt.Errorf("failed to switch profile: %w", err) + } + } + + if err := c.Globals.Config.Write(c.Globals.ConfigPath); err != nil { + return fmt.Errorf("failed to update config file: %w", err) + } + return nil +} + +// processCreateProfile handles creating a new profile. +func (c *RootCommand) processCreateProfile(ar auth.AuthorizationResult, profileName string) { + isDefault := true + if c.InvokedFromProfileCreate { + isDefault = c.ProfileDefault + } + + c.Globals.Config.Profiles = createNewProfile( + profileName, + c.customerID, + c.customerName, + isDefault, + c.Globals.Config.Profiles, + ar, + ) + + // If the user wants the newly created profile to be their new default, then + // we'll call Set for its side effect of resetting all other profiles to have + // their Default field set to false. + if c.ProfileDefault { // this is set by the `profile create` command. + if ps, ok := profile.SetDefault(c.ProfileCreateName, c.Globals.Config.Profiles); ok { + c.Globals.Config.Profiles = ps + } + } +} + +// processUpdateProfile handles updating a profile. +func (c *RootCommand) processUpdateProfile(ar auth.AuthorizationResult, profileName string) error { + var isDefault bool + if p := profile.Get(profileName, c.Globals.Config.Profiles); p != nil { + isDefault = p.Default + } + if c.InvokedFromProfileUpdate { + isDefault = c.ProfileDefault + } + ps, err := editProfile( + profileName, + c.customerID, + c.customerName, + isDefault, + c.Globals.Config.Profiles, + ar, + ) + if err != nil { + return err + } + c.Globals.Config.Profiles = ps + return nil +} + +// processSwitchProfile handles updating a profile. +func (c *RootCommand) processSwitchProfile(ar auth.AuthorizationResult, profileName string) error { + ps, err := editProfile( + profileName, + c.customerID, + c.customerName, + c.ProfileDefault, + c.Globals.Config.Profiles, + ar, + ) + if err != nil { + return err + } + ps, ok := profile.SetDefault(profileName, ps) + if !ok { + return fmt.Errorf("failed to set '%s' to be the default profile", profileName) + } + c.Globals.Config.Profiles = ps + return nil +} + +// IMPORTANT: Mutates the config.Profiles map type. +// We need to return the modified type so it can be safely reassigned. +func createNewProfile(profileName, customerID, customerName string, makeDefault bool, p config.Profiles, ar auth.AuthorizationResult) config.Profiles { + now := time.Now().Unix() + if p == nil { + p = make(config.Profiles) + } + p[profileName] = &config.Profile{ + AccessToken: ar.Jwt.AccessToken, + AccessTokenCreated: now, + AccessTokenTTL: ar.Jwt.ExpiresIn, + CustomerID: customerID, + CustomerName: customerName, + Default: makeDefault, + Email: ar.Email, + RefreshToken: ar.Jwt.RefreshToken, + RefreshTokenCreated: now, + RefreshTokenTTL: ar.Jwt.RefreshExpiresIn, + Token: ar.SessionToken, + } + return p +} + +// editProfile mutates the given profile with JWT details returned from the SSO +// authentication process. +// +// IMPORTANT: Mutates the config.Profiles map type. +// We need to return the modified type so it can be safely reassigned. +func editProfile(profileName, customerID, customerName string, makeDefault bool, p config.Profiles, ar auth.AuthorizationResult) (config.Profiles, error) { + ps, ok := profile.Edit(profileName, p, func(p *config.Profile) { + now := time.Now().Unix() + p.Default = makeDefault + p.AccessToken = ar.Jwt.AccessToken + p.AccessTokenCreated = now + p.AccessTokenTTL = ar.Jwt.ExpiresIn + p.CustomerID = customerID + p.CustomerName = customerName + p.Email = ar.Email + p.RefreshToken = ar.Jwt.RefreshToken + p.RefreshTokenCreated = now + p.RefreshTokenTTL = ar.Jwt.RefreshExpiresIn + p.Token = ar.SessionToken + }) + if !ok { + return ps, fsterr.RemediationError{ + Inner: fmt.Errorf("failed to update '%s' profile with new token data", profileName), + Remediation: "Run `fastly sso` to retry.", + } + } + return ps, nil +} diff --git a/pkg/commands/sso/sso_test.go b/pkg/commands/sso/sso_test.go new file mode 100644 index 000000000..fcea5ac95 --- /dev/null +++ b/pkg/commands/sso/sso_test.go @@ -0,0 +1,301 @@ +package sso_test + +import ( + "errors" + "testing" + "time" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/auth" + "github.com/fastly/cli/pkg/config" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" + "github.com/fastly/cli/pkg/threadsafe" +) + +func TestSSO(t *testing.T) { + scenarios := []testutil.CLIScenario{ + // 0. User cancels authentication prompt + { + Args: "sso", + Stdin: []string{ + "N", // when prompted to open a web browser to start authentication + }, + WantError: "will not continue", + }, + // 1. Error opening web browser + { + Args: "sso", + Stdin: []string{ + "Y", // when prompted to open a web browser to start authentication + }, + Setup: func(_ *testing.T, _ *testutil.CLIScenario, opts *global.Data) { + opts.Opener = func(_ string) error { + return errors.New("failed to open web browser") + } + }, + WantError: "failed to open web browser", + }, + // 2. Error processing OAuth flow (error encountered) + { + Args: "sso", + Stdin: []string{ + "Y", // when prompted to open a web browser to start authentication + }, + Setup: func(_ *testing.T, _ *testutil.CLIScenario, opts *global.Data) { + result := make(chan auth.AuthorizationResult) + opts.AuthServer = testutil.MockAuthServer{ + Result: result, + } + go func() { + result <- auth.AuthorizationResult{ + Err: errors.New("no authorization code returned"), + } + }() + }, + WantError: "failed to authorize: no authorization code returned", + }, + // 3. Error processing OAuth flow (empty SessionToken field) + { + Args: "sso", + Stdin: []string{ + "Y", // when prompted to open a web browser to start authentication + }, + Setup: func(_ *testing.T, _ *testutil.CLIScenario, opts *global.Data) { + result := make(chan auth.AuthorizationResult) + opts.AuthServer = testutil.MockAuthServer{ + Result: result, + } + go func() { + result <- auth.AuthorizationResult{ + SessionToken: "", + } + }() + }, + WantError: "failed to authorize: no session token", + }, + // 4. Success processing OAuth flow + { + Args: "sso", + Stdin: []string{ + "Y", // when prompted to open a web browser to start authentication + }, + Setup: func(_ *testing.T, _ *testutil.CLIScenario, opts *global.Data) { + result := make(chan auth.AuthorizationResult) + opts.AuthServer = testutil.MockAuthServer{ + Result: result, + } + go func() { + result <- auth.AuthorizationResult{ + SessionToken: "123", + } + }() + opts.HTTPClient = testutil.CurrentCustomerClient(testutil.CurrentCustomerResponse) + }, + WantOutputs: []string{ + "We're going to authenticate the 'user' profile", + "We need to open your browser to authenticate you.", + "Session token (persisted to your local configuration): 123", + }, + Validator: func(t *testing.T, _ *testutil.CLIScenario, opts *global.Data, _ *threadsafe.Buffer) { + const expectedToken = "123" + userProfile := opts.Config.Profiles["user"] + if userProfile.Token != expectedToken { + t.Errorf("want token: %s, got token: %s", expectedToken, userProfile.Token) + } + }, + }, + // 5. Success processing OAuth flow while setting specific profile (test_user) + { + Args: "sso test_user", + ConfigFile: &config.File{ + Profiles: config.Profiles{ + "test_user": &config.Profile{ + Default: true, + Email: "test@example.com", + Token: "mock-token", + }, + }, + }, + Stdin: []string{ + "Y", // when prompted to open a web browser to start authentication + }, + Setup: func(_ *testing.T, _ *testutil.CLIScenario, opts *global.Data) { + result := make(chan auth.AuthorizationResult) + opts.AuthServer = testutil.MockAuthServer{ + Result: result, + } + go func() { + result <- auth.AuthorizationResult{ + SessionToken: "123", + } + }() + opts.HTTPClient = testutil.CurrentCustomerClient(testutil.CurrentCustomerResponse) + }, + WantOutputs: []string{ + "We're going to authenticate the 'test_user' profile", + "We need to open your browser to authenticate you.", + "Session token (persisted to your local configuration): 123", + }, + Validator: func(t *testing.T, _ *testutil.CLIScenario, opts *global.Data, _ *threadsafe.Buffer) { + const expectedToken = "123" + userProfile := opts.Config.Profiles["test_user"] + if userProfile.Token != expectedToken { + t.Errorf("want token: %s, got token: %s", expectedToken, userProfile.Token) + } + }, + }, + // NOTE: The following tests indirectly validate our `app.Run()` logic. + // Specifically the processing of the token before invoking the subcommand. + // It allows us to check that the `sso` command is invoked when expected. + // + // 6. Success processing `whoami` command. + // We configure a non-SSO token so we can validate the INFO message. + // Otherwise no OAuth flow is happening here. + { + Args: "pops", + API: mock.API{ + AllDatacentersFn: func() ([]fastly.Datacenter, error) { + return []fastly.Datacenter{ + { + Name: fastly.ToPointer("Foobar"), + Code: fastly.ToPointer("FBR"), + Group: fastly.ToPointer("Bar"), + Shield: fastly.ToPointer("Baz"), + Coordinates: &fastly.Coordinates{ + Latitude: fastly.ToPointer(float64(1)), + Longitude: fastly.ToPointer(float64(2)), + X: fastly.ToPointer(float64(3)), + Y: fastly.ToPointer(float64(4)), + }, + }, + }, nil + }, + }, + ConfigFile: &config.File{ + Profiles: config.Profiles{ + "user": &config.Profile{ + Default: true, + Email: "test@example.com", + Token: "mock-token", + }, + }, + }, + Setup: func(_ *testing.T, _ *testutil.CLIScenario, opts *global.Data) { + opts.HTTPClient = testutil.CurrentCustomerClient(testutil.CurrentCustomerResponse) + }, + WantOutputs: []string{ + // FIXME: Put back messaging once SSO is GA. + // "is not a Fastly SSO (Single Sign-On) generated token", + "{Latitude:1 Longitude:2 X:3 Y:4}", + }, + Validator: func(t *testing.T, _ *testutil.CLIScenario, opts *global.Data, _ *threadsafe.Buffer) { + const expectedToken = "mock-token" + userProfile := opts.Config.Profiles["user"] + if userProfile.Token != expectedToken { + t.Errorf("want token: %s, got token: %s", expectedToken, userProfile.Token) + } + }, + }, + // 7. Success processing `whoami` command. + // We set an SSO token that has expired. + // This allows us to validate the output message about expiration. + // We don't respond "Y" to the prompt for reauthentication. + // But we've mocked the request to succeed still so it doesn't matter. + { + Args: "whoami", + ConfigFile: &config.File{ + Profiles: config.Profiles{ + "user": &config.Profile{ + AccessTokenCreated: time.Now().Add(-(time.Duration(600) * time.Second)).Unix(), // 10 mins ago + Default: true, + Email: "test@example.com", + Token: "mock-token", + }, + }, + }, + Setup: func(_ *testing.T, _ *testutil.CLIScenario, opts *global.Data) { + opts.HTTPClient = testutil.CurrentCustomerClient(testutil.CurrentCustomerResponse) + }, + WantOutput: "Your access token has expired and so has your refresh token.", + DontWantOutput: "{Latitude:1 Longitude:2 X:3 Y:4}", + Validator: func(t *testing.T, _ *testutil.CLIScenario, opts *global.Data, _ *threadsafe.Buffer) { + const expectedToken = "mock-token" + userProfile := opts.Config.Profiles["user"] + if userProfile.Token != expectedToken { + t.Errorf("want token: %s, got token: %s", expectedToken, userProfile.Token) + } + }, + }, + // 8. Success processing OAuth flow via `whoami` command + // We set an SSO token that has expired. + // This allows us to validate the output messages. + { + Args: "pops", + API: mock.API{ + AllDatacentersFn: func() ([]fastly.Datacenter, error) { + return []fastly.Datacenter{ + { + Name: fastly.ToPointer("Foobar"), + Code: fastly.ToPointer("FBR"), + Group: fastly.ToPointer("Bar"), + Shield: fastly.ToPointer("Baz"), + Coordinates: &fastly.Coordinates{ + Latitude: fastly.ToPointer(float64(1)), + Longitude: fastly.ToPointer(float64(2)), + X: fastly.ToPointer(float64(3)), + Y: fastly.ToPointer(float64(4)), + }, + }, + }, nil + }, + }, + ConfigFile: &config.File{ + Profiles: config.Profiles{ + "user": &config.Profile{ + AccessTokenCreated: time.Now().Add(-(time.Duration(300) * time.Second)).Unix(), // 5 mins ago + Default: true, + Email: "test@example.com", + Token: "mock-token", + }, + }, + }, + Stdin: []string{ + "Y", // when prompted to open a web browser to start authentication + }, + Setup: func(_ *testing.T, _ *testutil.CLIScenario, opts *global.Data) { + result := make(chan auth.AuthorizationResult) + opts.AuthServer = testutil.MockAuthServer{ + Result: result, + } + go func() { + result <- auth.AuthorizationResult{ + SessionToken: "123", + } + }() + opts.HTTPClient = testutil.CurrentCustomerClient(testutil.CurrentCustomerResponse) + }, + WantOutputs: []string{ + "Your access token has expired and so has your refresh token.", + "Starting a local server to handle the authentication flow.", + "Session token (persisted to your local configuration): 123", + "{Latitude:1 Longitude:2 X:3 Y:4}", + }, + Validator: func(t *testing.T, _ *testutil.CLIScenario, opts *global.Data, _ *threadsafe.Buffer) { + const expectedToken = "123" + userProfile := opts.Config.Profiles["user"] + if userProfile.Token != expectedToken { + t.Errorf("want token: %s, got token: %s", expectedToken, userProfile.Token) + } + }, + }, + } + + // unlike the usual usage of this function, the "command name" + // slice is empty here because the commands to be run are + // embedded in the scenarios (some scenarios run different + // commands) + testutil.RunCLIScenarios(t, []string{}, scenarios) +} diff --git a/pkg/commands/stats/doc.go b/pkg/commands/stats/doc.go new file mode 100644 index 000000000..21756bfb9 --- /dev/null +++ b/pkg/commands/stats/doc.go @@ -0,0 +1,2 @@ +// Package stats contains commands to inspect Fastly statistic data. +package stats diff --git a/pkg/commands/stats/historical.go b/pkg/commands/stats/historical.go new file mode 100644 index 000000000..1c02f6a3e --- /dev/null +++ b/pkg/commands/stats/historical.go @@ -0,0 +1,144 @@ +package stats + +import ( + "encoding/json" + "fmt" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" +) + +const statusSuccess = "success" + +// HistoricalCommand exposes the Historical Stats API. +type HistoricalCommand struct { + argparser.Base + + by string + formatFlag string + from string + region string + serviceName argparser.OptionalServiceNameID + to string +} + +// NewHistoricalCommand is the "stats historical" subcommand. +func NewHistoricalCommand(parent argparser.Registerer, g *global.Data) *HistoricalCommand { + var c HistoricalCommand + c.Globals = g + + c.CmdClause = parent.Command("historical", "View historical stats for a Fastly service") + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + + c.CmdClause.Flag("from", "From time, accepted formats at https://fastly.dev/reference/api/metrics-stats/historical-stats").StringVar(&c.from) + c.CmdClause.Flag("to", "To time").StringVar(&c.to) + c.CmdClause.Flag("by", "Aggregation period (minute/hour/day)").EnumVar(&c.by, "minute", "hour", "day") + c.CmdClause.Flag("region", "Filter by region ('stats regions' to list)").StringVar(&c.region) + + c.CmdClause.Flag("format", "Output format (json)").EnumVar(&c.formatFlag, "json") + + return &c +} + +// Exec implements the command interface. +func (c *HistoricalCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, source, flag, err := argparser.ServiceID(c.serviceName, *c.Globals.Manifest, c.Globals.APIClient, c.Globals.ErrLog) + if err != nil { + return err + } + if c.Globals.Verbose() { + argparser.DisplayServiceID(serviceID, flag, source, out) + } + + input := fastly.GetStatsInput{ + Service: fastly.ToPointer(serviceID), + } + if c.by != "" { + input.By = &c.by + } + if c.from != "" { + input.From = &c.from + } + if c.region != "" { + input.Region = &c.region + } + if c.to != "" { + input.To = &c.to + } + + var envelope statsResponse + err = c.Globals.APIClient.GetStatsJSON(&input, &envelope) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + }) + return err + } + + if envelope.Status != statusSuccess { + return fmt.Errorf("non-success response: %s", envelope.Msg) + } + + switch c.formatFlag { + case "json": + err := writeBlocksJSON(out, serviceID, envelope.Data) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + }) + } + + default: + writeHeader(out, envelope.Meta) + err := writeBlocks(out, serviceID, envelope.Data) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + }) + } + } + + return nil +} + +func writeHeader(out io.Writer, meta statsResponseMeta) { + fmt.Fprintf(out, "From: %s\n", meta.From) + fmt.Fprintf(out, "To: %s\n", meta.To) + fmt.Fprintf(out, "By: %s\n", meta.By) + fmt.Fprintf(out, "Region: %s\n", meta.Region) + fmt.Fprintf(out, "---\n") +} + +func writeBlocks(out io.Writer, service string, blocks []statsResponseData) error { + for _, block := range blocks { + if err := fmtBlock(out, service, block); err != nil { + return err + } + } + + return nil +} + +func writeBlocksJSON(out io.Writer, _ string, blocks []statsResponseData) error { + for _, block := range blocks { + if err := json.NewEncoder(out).Encode(block); err != nil { + return err + } + } + + return nil +} diff --git a/pkg/commands/stats/historical_test.go b/pkg/commands/stats/historical_test.go new file mode 100644 index 000000000..ef847cdea --- /dev/null +++ b/pkg/commands/stats/historical_test.go @@ -0,0 +1,109 @@ +package stats_test + +import ( + "bytes" + "encoding/json" + "io" + "strings" + "testing" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/app" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" +) + +func TestHistorical(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("stats historical --service-id=123"), + api: mock.API{GetStatsJSONFn: getStatsJSONOK}, + wantOutput: historicalOK, + }, + { + args: args("stats historical --service-id=123"), + api: mock.API{GetStatsJSONFn: getStatsJSONError}, + wantError: errTest.Error(), + }, + { + args: args("stats historical --service-id=123 --format=json"), + api: mock.API{GetStatsJSONFn: getStatsJSONOK}, + wantOutput: historicalJSONOK, + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertStringContains(t, stdout.String(), testcase.wantOutput) + }) + } +} + +var historicalOK = `From: Wed May 15 20:08:35 UTC 2013 +To: Thu May 16 20:08:35 UTC 2013 +By: day +Region: all +--- +Service ID: 123 +Start Time: 1970-01-01 00:00:00 +0000 UTC +-------------------------------------------------- +Hit Rate: 0.00% +Avg Hit Time: 0.00µs +Avg Miss Time: 0.00µs + +Request BW: 0 + Headers: 0 + Body: 0 + +Response BW: 0 + Headers: 0 + Body: 0 + +Requests: 0 + Hit: 0 + Miss: 0 + Pass: 0 + Synth: 0 + Error: 0 + Uncacheable: 0 +` + +var historicalJSONOK = `{"start_time":0} +` + +func getStatsJSONOK(_ *fastly.GetStatsInput, o any) error { + msg := []byte(` +{ + "status": "success", + "meta": { + "to": "Thu May 16 20:08:35 UTC 2013", + "from": "Wed May 15 20:08:35 UTC 2013", + "by": "day", + "region": "all" + }, + "msg": null, + "data": [{"start_time": 0}] +}`) + + return json.Unmarshal(msg, o) +} + +func getStatsJSONError(_ *fastly.GetStatsInput, _ any) error { + return errTest +} diff --git a/pkg/stats/obj.go b/pkg/commands/stats/obj.go similarity index 94% rename from pkg/stats/obj.go rename to pkg/commands/stats/obj.go index ac7e8f0da..2b4fe8157 100644 --- a/pkg/stats/obj.go +++ b/pkg/commands/stats/obj.go @@ -18,7 +18,7 @@ type statsResponseMeta struct { Region string `json:"region"` } -type statsResponseData map[string]interface{} +type statsResponseData map[string]any type realtimeResponse struct { Timestamp uint64 `json:"timestamp"` diff --git a/pkg/commands/stats/realtime.go b/pkg/commands/stats/realtime.go new file mode 100644 index 000000000..99a9c9c97 --- /dev/null +++ b/pkg/commands/stats/realtime.go @@ -0,0 +1,137 @@ +package stats + +import ( + "encoding/json" + "fmt" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/api" + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// RealtimeCommand exposes the Realtime Metrics API. +type RealtimeCommand struct { + argparser.Base + + formatFlag string + serviceName argparser.OptionalServiceNameID +} + +// NewRealtimeCommand is the "stats realtime" subcommand. +func NewRealtimeCommand(parent argparser.Registerer, g *global.Data) *RealtimeCommand { + var c RealtimeCommand + c.Globals = g + + c.CmdClause = parent.Command("realtime", "View realtime stats for a Fastly service") + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + + c.CmdClause.Flag("format", "Output format (json)").EnumVar(&c.formatFlag, "json") + + return &c +} + +// Exec implements the command interface. +func (c *RealtimeCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, source, flag, err := argparser.ServiceID(c.serviceName, *c.Globals.Manifest, c.Globals.APIClient, c.Globals.ErrLog) + if err != nil { + return err + } + if c.Globals.Verbose() { + argparser.DisplayServiceID(serviceID, flag, source, out) + } + + switch c.formatFlag { + case "json": + if err := loopJSON(c.Globals.RTSClient, serviceID, out); err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + }) + return err + } + + default: + if err := loopText(c.Globals.RTSClient, serviceID, out); err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + }) + return err + } + } + + return nil +} + +func loopJSON(client api.RealtimeStatsInterface, service string, out io.Writer) error { + var timestamp uint64 + for { + var envelope struct { + Timestamp uint64 `json:"timestamp"` + Data []json.RawMessage `json:"data"` + } + + err := client.GetRealtimeStatsJSON(&fastly.GetRealtimeStatsInput{ + ServiceID: service, + Timestamp: timestamp, + }, &envelope) + if err != nil { + text.Error(out, "fetching stats: %w", err) + continue + } + timestamp = envelope.Timestamp + + for _, data := range envelope.Data { + _, err = out.Write(data) + if err != nil { + return fmt.Errorf("error: unable to write data to stdout: %w", err) + } + text.Break(out) + } + } +} + +func loopText(client api.RealtimeStatsInterface, service string, out io.Writer) error { + var timestamp uint64 + for { + var envelope realtimeResponse + + err := client.GetRealtimeStatsJSON(&fastly.GetRealtimeStatsInput{ + ServiceID: service, + Timestamp: timestamp, + }, &envelope) + if err != nil { + text.Error(out, "fetching stats: %w", err) + continue + } + timestamp = envelope.Timestamp + + for _, block := range envelope.Data { + agg := block.Aggregated + + // FIXME: These are heavy-handed compatibility + // fixes for stats vs realtime, so we can use + // fmtBlock for both. + agg["start_time"] = block.Recorded + delete(agg, "miss_histogram") + + if err := fmtBlock(out, service, agg); err != nil { + text.Error(out, "formatting stats: %w", err) + continue + } + } + } +} diff --git a/pkg/commands/stats/regions.go b/pkg/commands/stats/regions.go new file mode 100644 index 000000000..680eaa677 --- /dev/null +++ b/pkg/commands/stats/regions.go @@ -0,0 +1,38 @@ +package stats + +import ( + "fmt" + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// RegionsCommand exposes the Stats Regions API. +type RegionsCommand struct { + argparser.Base +} + +// NewRegionsCommand returns a new command registered under parent. +func NewRegionsCommand(parent argparser.Registerer, g *global.Data) *RegionsCommand { + var c RegionsCommand + c.Globals = g + c.CmdClause = parent.Command("regions", "List stats regions") + return &c +} + +// Exec implements the command interface. +func (c *RegionsCommand) Exec(_ io.Reader, out io.Writer) error { + resp, err := c.Globals.APIClient.GetRegions() + if err != nil { + c.Globals.ErrLog.Add(err) + return fmt.Errorf("fetching regions: %w", err) + } + + for _, region := range resp.Data { + text.Output(out, "%s", region) + } + + return nil +} diff --git a/pkg/commands/stats/regions_test.go b/pkg/commands/stats/regions_test.go new file mode 100644 index 000000000..76b02d16d --- /dev/null +++ b/pkg/commands/stats/regions_test.go @@ -0,0 +1,63 @@ +package stats_test + +import ( + "bytes" + "errors" + "io" + "strings" + "testing" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/app" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" +) + +func TestRegions(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: args("stats regions"), + api: mock.API{GetRegionsFn: getRegionsOK}, + wantOutput: "foo\nbar\nbaz\n", + }, + { + args: args("stats regions"), + api: mock.API{GetRegionsFn: getRegionsError}, + wantError: errTest.Error(), + }, + } + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.APIClientFactory = mock.APIClient(testcase.api) + return opts, nil + } + err := app.Run(testcase.args, nil) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertStringContains(t, stdout.String(), testcase.wantOutput) + }) + } +} + +func getRegionsOK() (*fastly.RegionsResponse, error) { + return &fastly.RegionsResponse{ + Data: []string{"foo", "bar", "baz"}, + }, nil +} + +var errTest = errors.New("fixture error") + +func getRegionsError() (*fastly.RegionsResponse, error) { + return nil, errTest +} diff --git a/pkg/commands/stats/root.go b/pkg/commands/stats/root.go new file mode 100644 index 000000000..ac0e1d720 --- /dev/null +++ b/pkg/commands/stats/root.go @@ -0,0 +1,29 @@ +package stats + +import ( + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" +) + +// RootCommand dispatches all "stats" commands. +type RootCommand struct { + argparser.Base +} + +// CommandName is the string to be used to invoke this command. +const CommandName = "stats" + +// NewRootCommand returns a new top level "stats" command. +func NewRootCommand(parent argparser.Registerer, g *global.Data) *RootCommand { + var c RootCommand + c.Globals = g + c.CmdClause = parent.Command(CommandName, "View historical and realtime statistics for a Fastly service") + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { + panic("unreachable") +} diff --git a/pkg/commands/stats/template.go b/pkg/commands/stats/template.go new file mode 100644 index 000000000..4a512e6f8 --- /dev/null +++ b/pkg/commands/stats/template.go @@ -0,0 +1,85 @@ +package stats + +import ( + "fmt" + "io" + "text/template" + "time" + + "github.com/mitchellh/mapstructure" + + "github.com/fastly/go-fastly/v10/fastly" +) + +var blockTemplate = template.Must(template.New("stats_block").Parse( + `Service ID: {{ .ServiceID }} +Start Time: {{ .StartTime }} +-------------------------------------------------- +Hit Rate: {{ .HitRate }} +Avg Hit Time: {{ .AvgHitTime }} +Avg Miss Time: {{ .AvgMissTime }} + +Request BW: {{ .RequestBytes }} + Headers: {{ .RequestHeaderBytes }} + Body: {{ .RequestBodyBytes }} + +Response BW: {{ .ResponseBytes }} + Headers: {{ .ResponseHeaderBytes }} + Body: {{ .ResponseBodyBytes }} + +Requests: {{ .RequestCount }} + Hit: {{ .Hits }} + Miss: {{ .Miss }} + Pass: {{ .Pass }} + Synth: {{ .Synth }} + Error: {{ .Errors }} + Uncacheable: {{ .Uncacheable }} + +`)) + +func fmtBlock(out io.Writer, service string, block statsResponseData) error { + var agg fastly.Stats + if err := mapstructure.Decode(block, &agg); err != nil { + return err + } + + hitRate := 0.0 + aggHits := fastly.ToValue(agg.Hits) + aggMiss := fastly.ToValue(agg.Miss) + aggErrs := fastly.ToValue(agg.Errors) + if aggHits > 0 { + hitRate = float64(aggHits-aggMiss-aggErrs) / float64(aggHits) + } + + // TODO: parse the JSON more strictly so this doesn't need to be dynamic. + st, ok := block["start_time"].(float64) + if !ok { + return fmt.Errorf("failed to type assert '%v' to a float64", block["start_time"]) + } + startTime := time.Unix(int64(st), 0).UTC() + + values := map[string]string{ + "ServiceID": fmt.Sprintf("%30s", service), + "StartTime": fmt.Sprintf("%30s", startTime), + "HitRate": fmt.Sprintf("%29.2f%%", hitRate*100), + "AvgHitTime": fmt.Sprintf("%28.2f\u00b5s", fastly.ToValue(agg.HitsTime)*1000), + "AvgMissTime": fmt.Sprintf("%28.2f\u00b5s", fastly.ToValue(agg.MissTime)*1000), + + "RequestBytes": fmt.Sprintf("%30d", fastly.ToValue(agg.RequestHeaderBytes)+fastly.ToValue(agg.RequestBodyBytes)), + "RequestHeaderBytes": fmt.Sprintf("%30d", fastly.ToValue(agg.RequestHeaderBytes)), + "RequestBodyBytes": fmt.Sprintf("%30d", fastly.ToValue(agg.RequestBodyBytes)), + "ResponseBytes": fmt.Sprintf("%30d", fastly.ToValue(agg.ResponseHeaderBytes)+fastly.ToValue(agg.ResponseBodyBytes)), + "ResponseHeaderBytes": fmt.Sprintf("%30d", fastly.ToValue(agg.ResponseHeaderBytes)), + "ResponseBodyBytes": fmt.Sprintf("%30d", fastly.ToValue(agg.ResponseBodyBytes)), + + "RequestCount": fmt.Sprintf("%30d", fastly.ToValue(agg.Requests)), + "Hits": fmt.Sprintf("%30d", aggHits), + "Miss": fmt.Sprintf("%30d", aggMiss), + "Pass": fmt.Sprintf("%30d", fastly.ToValue(agg.Pass)), + "Synth": fmt.Sprintf("%30d", fastly.ToValue(agg.Synth)), + "Errors": fmt.Sprintf("%30d", aggErrs), + "Uncacheable": fmt.Sprintf("%30d", fastly.ToValue(agg.Uncachable)), + } + + return blockTemplate.Execute(out, values) +} diff --git a/pkg/commands/tls/config/config_test.go b/pkg/commands/tls/config/config_test.go new file mode 100644 index 000000000..fb234ca2b --- /dev/null +++ b/pkg/commands/tls/config/config_test.go @@ -0,0 +1,150 @@ +package config_test + +import ( + "fmt" + "testing" + + "github.com/fastly/go-fastly/v10/fastly" + + root "github.com/fastly/cli/pkg/commands/tls/config" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" +) + +const ( + validateAPIError = "validate API error" + validateAPISuccess = "validate API success" + mockResponseID = "123" +) + +func TestDescribe(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --id flag", + WantError: "error parsing arguments: required flag --id not provided", + }, + { + Name: validateAPIError, + API: mock.API{ + GetCustomTLSConfigurationFn: func(_ *fastly.GetCustomTLSConfigurationInput) (*fastly.CustomTLSConfiguration, error) { + return nil, testutil.Err + }, + }, + Args: "--id example", + WantError: testutil.Err.Error(), + }, + { + Name: validateAPISuccess, + API: mock.API{ + GetCustomTLSConfigurationFn: func(_ *fastly.GetCustomTLSConfigurationInput) (*fastly.CustomTLSConfiguration, error) { + t := testutil.Date + return &fastly.CustomTLSConfiguration{ + ID: mockResponseID, + Name: "Foo", + DNSRecords: []*fastly.DNSRecord{ + { + ID: "456", + RecordType: "Bar", + Region: "Baz", + }, + }, + Bulk: true, + Default: true, + HTTPProtocols: []string{"1.1"}, + TLSProtocols: []string{"1.3"}, + CreatedAt: &t, + UpdatedAt: &t, + }, nil + }, + }, + Args: "--id example", + WantOutput: "\nID: " + mockResponseID + "\nName: Foo\nDNS Record ID: 456\nDNS Record Type: Bar\nDNS Record Region: Baz\nBulk: true\nDefault: true\nHTTP Protocol: 1.1\nTLS Protocol: 1.3\nCreated at: 2021-06-15 23:00:00 +0000 UTC\nUpdated at: 2021-06-15 23:00:00 +0000 UTC\n", + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, "describe"}, scenarios) +} + +func TestList(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: validateAPIError, + API: mock.API{ + ListCustomTLSConfigurationsFn: func(_ *fastly.ListCustomTLSConfigurationsInput) ([]*fastly.CustomTLSConfiguration, error) { + return nil, testutil.Err + }, + }, + WantError: testutil.Err.Error(), + }, + { + Name: validateAPISuccess, + API: mock.API{ + ListCustomTLSConfigurationsFn: func(_ *fastly.ListCustomTLSConfigurationsInput) ([]*fastly.CustomTLSConfiguration, error) { + t := testutil.Date + return []*fastly.CustomTLSConfiguration{ + { + ID: mockResponseID, + Name: "Foo", + DNSRecords: []*fastly.DNSRecord{ + { + ID: "456", + RecordType: "Bar", + Region: "Baz", + }, + }, + Bulk: true, + Default: true, + HTTPProtocols: []string{"1.1"}, + TLSProtocols: []string{"1.3"}, + CreatedAt: &t, + UpdatedAt: &t, + }, + }, nil + }, + }, + Args: "--verbose", + WantOutput: "\nID: " + mockResponseID + "\nName: Foo\nDNS Record ID: 456\nDNS Record Type: Bar\nDNS Record Region: Baz\nBulk: true\nDefault: true\nHTTP Protocol: 1.1\nTLS Protocol: 1.3\nCreated at: 2021-06-15 23:00:00 +0000 UTC\nUpdated at: 2021-06-15 23:00:00 +0000 UTC\n", + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, "list"}, scenarios) +} + +func TestUpdate(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --id flag", + Args: "--name example", + WantError: "error parsing arguments: required flag --id not provided", + }, + { + Name: "validate missing --name flag", + Args: "--id 123", + WantError: "error parsing arguments: required flag --name not provided", + }, + { + Name: validateAPIError, + API: mock.API{ + UpdateCustomTLSConfigurationFn: func(_ *fastly.UpdateCustomTLSConfigurationInput) (*fastly.CustomTLSConfiguration, error) { + return nil, testutil.Err + }, + }, + Args: "--id example --name example", + WantError: testutil.Err.Error(), + }, + { + Name: validateAPISuccess, + API: mock.API{ + UpdateCustomTLSConfigurationFn: func(_ *fastly.UpdateCustomTLSConfigurationInput) (*fastly.CustomTLSConfiguration, error) { + return &fastly.CustomTLSConfiguration{ + ID: mockResponseID, + }, nil + }, + }, + Args: "--id example --name example", + WantOutput: fmt.Sprintf("Updated TLS Configuration '%s'", mockResponseID), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, "update"}, scenarios) +} diff --git a/pkg/commands/tls/config/describe.go b/pkg/commands/tls/config/describe.go new file mode 100644 index 000000000..ce6939125 --- /dev/null +++ b/pkg/commands/tls/config/describe.go @@ -0,0 +1,115 @@ +package config + +import ( + "fmt" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" +) + +const include = "dns_records" + +// NewDescribeCommand returns a usable command registered under the parent. +func NewDescribeCommand(parent argparser.Registerer, g *global.Data) *DescribeCommand { + var c DescribeCommand + c.CmdClause = parent.Command("describe", "Show a TLS configuration").Alias("get") + c.Globals = g + + // Required. + c.CmdClause.Flag("id", "Alphanumeric string identifying a TLS configuration").Required().StringVar(&c.id) + + // Optional. + c.CmdClause.Flag("include", "Include related objects (comma-separated values)").HintOptions(include).EnumVar(&c.include, include) + c.RegisterFlagBool(c.JSONFlag()) // --json + + return &c +} + +// DescribeCommand calls the Fastly API to describe an appropriate resource. +type DescribeCommand struct { + argparser.Base + argparser.JSONOutput + + id string + include string +} + +// Exec invokes the application logic for the command. +func (c *DescribeCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + input := c.constructInput() + + o, err := c.Globals.APIClient.GetCustomTLSConfiguration(input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "TLS Configuration ID": c.id, + }) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + return c.print(out, o) +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *DescribeCommand) constructInput() *fastly.GetCustomTLSConfigurationInput { + var input fastly.GetCustomTLSConfigurationInput + + input.ID = c.id + + if c.include != "" { + input.Include = c.include + } + + return &input +} + +// print displays the information returned from the API. +func (c *DescribeCommand) print(out io.Writer, r *fastly.CustomTLSConfiguration) error { + fmt.Fprintf(out, "\nID: %s\n", r.ID) + fmt.Fprintf(out, "Name: %s\n", r.Name) + + if len(r.DNSRecords) > 0 { + for _, v := range r.DNSRecords { + if v != nil { + fmt.Fprintf(out, "DNS Record ID: %s\n", v.ID) + fmt.Fprintf(out, "DNS Record Type: %s\n", v.RecordType) + fmt.Fprintf(out, "DNS Record Region: %s\n", v.Region) + } + } + } + + fmt.Fprintf(out, "Bulk: %t\n", r.Bulk) + fmt.Fprintf(out, "Default: %t\n", r.Default) + + if len(r.HTTPProtocols) > 0 { + for _, v := range r.HTTPProtocols { + fmt.Fprintf(out, "HTTP Protocol: %s\n", v) + } + } + + if len(r.TLSProtocols) > 0 { + for _, v := range r.TLSProtocols { + fmt.Fprintf(out, "TLS Protocol: %s\n", v) + } + } + + if r.CreatedAt != nil { + fmt.Fprintf(out, "Created at: %s\n", r.CreatedAt) + } + if r.UpdatedAt != nil { + fmt.Fprintf(out, "Updated at: %s\n", r.UpdatedAt) + } + + return nil +} diff --git a/pkg/commands/tls/config/doc.go b/pkg/commands/tls/config/doc.go new file mode 100644 index 000000000..bd1c2e874 --- /dev/null +++ b/pkg/commands/tls/config/doc.go @@ -0,0 +1,3 @@ +// Package config contains commands to inspect and manipulate Fastly TLS +// configuration. +package config diff --git a/pkg/commands/tls/config/list.go b/pkg/commands/tls/config/list.go new file mode 100644 index 000000000..7f8c4d751 --- /dev/null +++ b/pkg/commands/tls/config/list.go @@ -0,0 +1,164 @@ +package config + +import ( + "fmt" + "io" + "strings" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// NewListCommand returns a usable command registered under the parent. +func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { + var c ListCommand + c.CmdClause = parent.Command("list", "List all TLS configurations") + c.Globals = g + + // Optional. + c.CmdClause.Flag("filter-bulk", "Optionally filter by the bulk attribute").Action(c.filterBulk.Set).BoolVar(&c.filterBulk.Value) + c.CmdClause.Flag("include", "Include related objects (comma-separated values)").HintOptions(include).EnumVar(&c.include, include) + c.RegisterFlagBool(c.JSONFlag()) // --json + c.CmdClause.Flag("page", "Page number of data set to fetch").IntVar(&c.pageNumber) + c.CmdClause.Flag("per-page", "Number of records per page").IntVar(&c.pageSize) + + return &c +} + +// ListCommand calls the Fastly API to list appropriate resources. +type ListCommand struct { + argparser.Base + argparser.JSONOutput + + filterBulk argparser.OptionalBool + include string + pageNumber int + pageSize int +} + +// Exec invokes the application logic for the command. +func (c *ListCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + input := c.constructInput() + + o, err := c.Globals.APIClient.ListCustomTLSConfigurations(input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Filter Bulk": c.filterBulk, + "Include": c.include, + "Page Number": c.pageNumber, + "Page Size": c.pageSize, + }) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + if c.Globals.Verbose() { + c.printVerbose(out, o) + } else { + err = c.printSummary(out, o) + if err != nil { + return err + } + } + return nil +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *ListCommand) constructInput() *fastly.ListCustomTLSConfigurationsInput { + var input fastly.ListCustomTLSConfigurationsInput + + if c.filterBulk.WasSet { + input.FilterBulk = c.filterBulk.Value + } + if c.include != "" { + input.Include = c.include + } + if c.pageNumber > 0 { + input.PageNumber = c.pageNumber + } + if c.pageSize > 0 { + input.PageSize = c.pageSize + } + + return &input +} + +// printVerbose displays the information returned from the API in a verbose +// format. +func (c *ListCommand) printVerbose(out io.Writer, rs []*fastly.CustomTLSConfiguration) { + for _, r := range rs { + fmt.Fprintf(out, "ID: %s\n", r.ID) + fmt.Fprintf(out, "Name: %s\n", r.Name) + + if len(r.DNSRecords) > 0 { + for _, v := range r.DNSRecords { + if v != nil { + fmt.Fprintf(out, "DNS Record ID: %s\n", v.ID) + fmt.Fprintf(out, "DNS Record Type: %s\n", v.RecordType) + fmt.Fprintf(out, "DNS Record Region: %s\n", v.Region) + } + } + } + + fmt.Fprintf(out, "Bulk: %t\n", r.Bulk) + fmt.Fprintf(out, "Default: %t\n", r.Default) + + if len(r.HTTPProtocols) > 0 { + for _, v := range r.HTTPProtocols { + fmt.Fprintf(out, "HTTP Protocol: %s\n", v) + } + } + + if len(r.TLSProtocols) > 0 { + for _, v := range r.TLSProtocols { + fmt.Fprintf(out, "TLS Protocol: %s\n", v) + } + } + + if r.CreatedAt != nil { + fmt.Fprintf(out, "Created at: %s\n", r.CreatedAt) + } + if r.UpdatedAt != nil { + fmt.Fprintf(out, "Updated at: %s\n", r.UpdatedAt) + } + + fmt.Fprintf(out, "\n") + } +} + +// printSummary displays the information returned from the API in a summarised +// format. +func (c *ListCommand) printSummary(out io.Writer, rs []*fastly.CustomTLSConfiguration) error { + t := text.NewTable(out) + t.AddHeader("NAME", "ID", "BULK", "DEFAULT", "TLS PROTOCOLS", "HTTP PROTOCOLS", "DNS RECORDS") + for _, r := range rs { + drs := make([]string, len(r.DNSRecords)) + for i, v := range r.DNSRecords { + if v != nil { + drs[i] = v.ID + } + } + t.AddLine( + r.Name, + r.ID, + r.Bulk, + r.Default, + strings.Join(r.TLSProtocols, ", "), + strings.Join(r.HTTPProtocols, ", "), + strings.Join(drs, ", "), + ) + } + t.Print() + return nil +} diff --git a/pkg/commands/tls/config/root.go b/pkg/commands/tls/config/root.go new file mode 100644 index 000000000..555abbca0 --- /dev/null +++ b/pkg/commands/tls/config/root.go @@ -0,0 +1,31 @@ +package config + +import ( + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" +) + +// RootCommand is the parent command for all subcommands in this package. +// It should be installed under the primary root command. +type RootCommand struct { + argparser.Base + // no flags +} + +// CommandName is the string to be used to invoke this command. +const CommandName = "tls-config" + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent argparser.Registerer, g *global.Data) *RootCommand { + var c RootCommand + c.Globals = g + c.CmdClause = parent.Command(CommandName, "Apply configuration options for each TLS enabled domain") + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { + panic("unreachable") +} diff --git a/pkg/commands/tls/config/update.go b/pkg/commands/tls/config/update.go new file mode 100644 index 000000000..e73046f6a --- /dev/null +++ b/pkg/commands/tls/config/update.go @@ -0,0 +1,57 @@ +package config + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// NewUpdateCommand returns a usable command registered under the parent. +func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { + var c UpdateCommand + c.CmdClause = parent.Command("update", "Update a TLS configuration") + c.Globals = g + + // Required. + c.CmdClause.Flag("id", "Alphanumeric string identifying a TLS configuration").Required().StringVar(&c.id) + c.CmdClause.Flag("name", "A custom name for your TLS configuration").Required().StringVar(&c.name) + return &c +} + +// UpdateCommand calls the Fastly API to update an appropriate resource. +type UpdateCommand struct { + argparser.Base + + id string + name string +} + +// Exec invokes the application logic for the command. +func (c *UpdateCommand) Exec(_ io.Reader, out io.Writer) error { + input := c.constructInput() + + r, err := c.Globals.APIClient.UpdateCustomTLSConfiguration(input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "TLS Configuration ID": c.id, + }) + return err + } + + text.Success(out, "Updated TLS Configuration '%s'", r.ID) + return nil +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *UpdateCommand) constructInput() *fastly.UpdateCustomTLSConfigurationInput { + var input fastly.UpdateCustomTLSConfigurationInput + + input.ID = c.id + input.Name = c.name + + return &input +} diff --git a/pkg/commands/tls/custom/activation/activation_test.go b/pkg/commands/tls/custom/activation/activation_test.go new file mode 100644 index 000000000..9eaab67af --- /dev/null +++ b/pkg/commands/tls/custom/activation/activation_test.go @@ -0,0 +1,203 @@ +package activation_test + +import ( + "fmt" + "testing" + + "github.com/fastly/go-fastly/v10/fastly" + + root "github.com/fastly/cli/pkg/commands/tls/custom" + sub "github.com/fastly/cli/pkg/commands/tls/custom/activation" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" +) + +const ( + mockResponseID = "123" + mockResponseCertID = "456" + validateAPIError = "validate API error" + validateAPISuccess = "validate API success" + validateMissingIDFlag = "validate missing --id flag" +) + +func TestTLSCustomActivationEnable(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: validateMissingIDFlag, + Args: "--cert-id example", + WantError: "required flag --id not provided", + }, + { + Name: validateMissingIDFlag, + Args: "--id example", + WantError: "required flag --cert-id not provided", + }, + { + Name: validateAPIError, + API: mock.API{ + CreateTLSActivationFn: func(_ *fastly.CreateTLSActivationInput) (*fastly.TLSActivation, error) { + return nil, testutil.Err + }, + }, + Args: "--cert-id example --id example", + WantError: testutil.Err.Error(), + }, + { + Name: validateAPISuccess, + API: mock.API{ + CreateTLSActivationFn: func(_ *fastly.CreateTLSActivationInput) (*fastly.TLSActivation, error) { + return &fastly.TLSActivation{ + ID: mockResponseID, + Certificate: &fastly.CustomTLSCertificate{ + ID: mockResponseCertID, + }, + }, nil + }, + }, + Args: "--cert-id example --id example", + WantOutput: fmt.Sprintf("Enabled TLS Activation '%s' (Certificate '%s')", mockResponseID, mockResponseCertID), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "enable"}, scenarios) +} + +func TestTLSCustomActivationDisable(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: validateMissingIDFlag, + WantError: "error parsing arguments: required flag --id not provided", + }, + { + Name: validateAPIError, + API: mock.API{ + DeleteTLSActivationFn: func(_ *fastly.DeleteTLSActivationInput) error { + return testutil.Err + }, + }, + Args: "--id example", + WantError: testutil.Err.Error(), + }, + { + Name: validateAPISuccess, + API: mock.API{ + DeleteTLSActivationFn: func(_ *fastly.DeleteTLSActivationInput) error { + return nil + }, + }, + Args: "--id example", + WantOutput: "Disabled TLS Activation 'example'", + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "disable"}, scenarios) +} + +func TestTLSCustomActivationDescribe(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: validateMissingIDFlag, + WantError: "error parsing arguments: required flag --id not provided", + }, + { + Name: validateAPIError, + API: mock.API{ + GetTLSActivationFn: func(_ *fastly.GetTLSActivationInput) (*fastly.TLSActivation, error) { + return nil, testutil.Err + }, + }, + Args: "--id example", + WantError: testutil.Err.Error(), + }, + { + Name: validateAPISuccess, + API: mock.API{ + GetTLSActivationFn: func(_ *fastly.GetTLSActivationInput) (*fastly.TLSActivation, error) { + t := testutil.Date + return &fastly.TLSActivation{ + ID: mockResponseID, + CreatedAt: &t, + }, nil + }, + }, + Args: "--id example", + WantOutput: "\nID: " + mockResponseID + "\nCreated at: 2021-06-15 23:00:00 +0000 UTC\n", + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "describe"}, scenarios) +} + +func TestTLSCustomActivationList(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: validateAPIError, + API: mock.API{ + ListTLSActivationsFn: func(_ *fastly.ListTLSActivationsInput) ([]*fastly.TLSActivation, error) { + return nil, testutil.Err + }, + }, + WantError: testutil.Err.Error(), + }, + { + Name: validateAPISuccess, + API: mock.API{ + ListTLSActivationsFn: func(_ *fastly.ListTLSActivationsInput) ([]*fastly.TLSActivation, error) { + t := testutil.Date + return []*fastly.TLSActivation{ + { + ID: mockResponseID, + CreatedAt: &t, + }, + }, nil + }, + }, + Args: "--verbose", + WantOutput: "\nID: " + mockResponseID + "\nCreated at: 2021-06-15 23:00:00 +0000 UTC\n", + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "list"}, scenarios) +} + +func TestTLSCustomActivationUpdate(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: validateMissingIDFlag, + Args: "--cert-id example", + WantError: "required flag --id not provided", + }, + { + Name: validateMissingIDFlag, + Args: "--id example", + WantError: "required flag --cert-id not provided", + }, + { + Name: validateAPIError, + API: mock.API{ + UpdateTLSActivationFn: func(_ *fastly.UpdateTLSActivationInput) (*fastly.TLSActivation, error) { + return nil, testutil.Err + }, + }, + Args: "--cert-id example --id example", + WantError: testutil.Err.Error(), + }, + { + Name: validateAPISuccess, + API: mock.API{ + UpdateTLSActivationFn: func(_ *fastly.UpdateTLSActivationInput) (*fastly.TLSActivation, error) { + return &fastly.TLSActivation{ + ID: mockResponseID, + Certificate: &fastly.CustomTLSCertificate{ + ID: mockResponseCertID, + }, + }, nil + }, + }, + Args: "--cert-id example --id example", + WantOutput: fmt.Sprintf("Updated TLS Activation Certificate '%s' (previously: 'example')", mockResponseCertID), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "update"}, scenarios) +} diff --git a/pkg/commands/tls/custom/activation/create.go b/pkg/commands/tls/custom/activation/create.go new file mode 100644 index 000000000..374e1b27d --- /dev/null +++ b/pkg/commands/tls/custom/activation/create.go @@ -0,0 +1,59 @@ +package activation + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// NewCreateCommand returns a usable command registered under the parent. +func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { + var c CreateCommand + c.CmdClause = parent.Command("enable", "Enable TLS for a particular TLS domain and certificate combination").Alias("add") + c.Globals = g + + // Required. + c.CmdClause.Flag("cert-id", "Alphanumeric string identifying a TLS certificate").Required().StringVar(&c.certID) + c.CmdClause.Flag("id", "Alphanumeric string identifying a TLS activation").Required().StringVar(&c.id) + + return &c +} + +// CreateCommand calls the Fastly API to create an appropriate resource. +type CreateCommand struct { + argparser.Base + + certID string + id string +} + +// Exec invokes the application logic for the command. +func (c *CreateCommand) Exec(_ io.Reader, out io.Writer) error { + input := c.constructInput() + + r, err := c.Globals.APIClient.CreateTLSActivation(input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "TLS Activation ID": c.id, + "TLS Activation Certificate ID": c.certID, + }) + return err + } + + text.Success(out, "Enabled TLS Activation '%s' (Certificate '%s')", r.ID, r.Certificate.ID) + return nil +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *CreateCommand) constructInput() *fastly.CreateTLSActivationInput { + var input fastly.CreateTLSActivationInput + + input.ID = c.id + input.Certificate = &fastly.CustomTLSCertificate{ID: c.certID} + + return &input +} diff --git a/pkg/commands/tls/custom/activation/delete.go b/pkg/commands/tls/custom/activation/delete.go new file mode 100644 index 000000000..f79568bb6 --- /dev/null +++ b/pkg/commands/tls/custom/activation/delete.go @@ -0,0 +1,55 @@ +package activation + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// NewDeleteCommand returns a usable command registered under the parent. +func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { + var c DeleteCommand + c.CmdClause = parent.Command("disable", "Disable TLS on the domain associated with this TLS activation").Alias("remove") + c.Globals = g + + // Required. + c.CmdClause.Flag("id", "Alphanumeric string identifying a TLS activation").Required().StringVar(&c.id) + + return &c +} + +// DeleteCommand calls the Fastly API to delete an appropriate resource. +type DeleteCommand struct { + argparser.Base + + id string +} + +// Exec invokes the application logic for the command. +func (c *DeleteCommand) Exec(_ io.Reader, out io.Writer) error { + input := c.constructInput() + + err := c.Globals.APIClient.DeleteTLSActivation(input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "TLS Activation ID": c.id, + }) + return err + } + + text.Success(out, "Disabled TLS Activation '%s'", c.id) + return nil +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *DeleteCommand) constructInput() *fastly.DeleteTLSActivationInput { + var input fastly.DeleteTLSActivationInput + + input.ID = c.id + + return &input +} diff --git a/pkg/commands/tls/custom/activation/describe.go b/pkg/commands/tls/custom/activation/describe.go new file mode 100644 index 000000000..9468c6d64 --- /dev/null +++ b/pkg/commands/tls/custom/activation/describe.go @@ -0,0 +1,86 @@ +package activation + +import ( + "fmt" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" +) + +var include = []string{"tls_certificate", "tls_configuration", "tls_domain"} + +// NewDescribeCommand returns a usable command registered under the parent. +func NewDescribeCommand(parent argparser.Registerer, g *global.Data) *DescribeCommand { + var c DescribeCommand + c.CmdClause = parent.Command("describe", "Show a TLS configuration").Alias("get") + c.Globals = g + + // Required. + c.CmdClause.Flag("id", "Alphanumeric string identifying a TLS activation").Required().StringVar(&c.id) + + // Optional. + c.CmdClause.Flag("include", "Include related objects (comma-separated values)").HintOptions(include...).EnumVar(&c.include, include...) + c.RegisterFlagBool(c.JSONFlag()) // --json + + return &c +} + +// DescribeCommand calls the Fastly API to describe an appropriate resource. +type DescribeCommand struct { + argparser.Base + argparser.JSONOutput + + id string + include string +} + +// Exec invokes the application logic for the command. +func (c *DescribeCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + input := c.constructInput() + + o, err := c.Globals.APIClient.GetTLSActivation(input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "TLS Activation ID": c.id, + }) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + return c.print(out, o) +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *DescribeCommand) constructInput() *fastly.GetTLSActivationInput { + var input fastly.GetTLSActivationInput + + input.ID = c.id + + if c.include != "" { + input.Include = &c.include + } + + return &input +} + +// print displays the information returned from the API. +func (c *DescribeCommand) print(out io.Writer, r *fastly.TLSActivation) error { + fmt.Fprintf(out, "\nID: %s\n", r.ID) + + if r.CreatedAt != nil { + fmt.Fprintf(out, "Created at: %s\n", r.CreatedAt) + } + + return nil +} diff --git a/pkg/commands/tls/custom/activation/doc.go b/pkg/commands/tls/custom/activation/doc.go new file mode 100644 index 000000000..cfd2f1ad1 --- /dev/null +++ b/pkg/commands/tls/custom/activation/doc.go @@ -0,0 +1,3 @@ +// Package activation contains commands to inspect and manipulate Fastly custom +// TLS activations. +package activation diff --git a/pkg/commands/tls/custom/activation/list.go b/pkg/commands/tls/custom/activation/list.go new file mode 100644 index 000000000..83ca257e4 --- /dev/null +++ b/pkg/commands/tls/custom/activation/list.go @@ -0,0 +1,132 @@ +package activation + +import ( + "fmt" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// NewListCommand returns a usable command registered under the parent. +func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { + var c ListCommand + c.CmdClause = parent.Command("list", "List all TLS activations") + c.Globals = g + + // Optional. + c.CmdClause.Flag("filter-cert", "Limit the returned activations to a specific certificate").StringVar(&c.filterTLSCertID) + c.CmdClause.Flag("filter-config", "Limit the returned activations to a specific TLS configuration").StringVar(&c.filterTLSConfigID) + c.CmdClause.Flag("filter-domain", "Limit the returned rules to a specific domain name").StringVar(&c.filterTLSDomainID) + c.CmdClause.Flag("include", "Include related objects (comma-separated values)").HintOptions(include...).EnumVar(&c.include, include...) + c.RegisterFlagBool(c.JSONFlag()) // --json + c.CmdClause.Flag("page", "Page number of data set to fetch").IntVar(&c.pageNumber) + c.CmdClause.Flag("per-page", "Number of records per page").IntVar(&c.pageSize) + + return &c +} + +// ListCommand calls the Fastly API to list appropriate resources. +type ListCommand struct { + argparser.Base + argparser.JSONOutput + + filterTLSCertID string + filterTLSConfigID string + filterTLSDomainID string + include string + pageNumber int + pageSize int +} + +// Exec invokes the application logic for the command. +func (c *ListCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + input := c.constructInput() + + o, err := c.Globals.APIClient.ListTLSActivations(input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Filter TLS Certificate ID": c.filterTLSCertID, + "Filter TLS Configuration ID": c.filterTLSConfigID, + "Filter TLS Domain ID": c.filterTLSDomainID, + "Include": c.include, + "Page Number": c.pageNumber, + "Page Size": c.pageSize, + }) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + if c.Globals.Verbose() { + c.printVerbose(out, o) + } else { + err = c.printSummary(out, o) + if err != nil { + return err + } + } + return nil +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *ListCommand) constructInput() *fastly.ListTLSActivationsInput { + var input fastly.ListTLSActivationsInput + + if c.filterTLSCertID != "" { + input.FilterTLSCertificateID = c.filterTLSCertID + } + if c.filterTLSConfigID != "" { + input.FilterTLSConfigurationID = c.filterTLSConfigID + } + if c.filterTLSDomainID != "" { + input.FilterTLSDomainID = c.filterTLSDomainID + } + if c.include != "" { + input.Include = c.include + } + if c.pageNumber > 0 { + input.PageNumber = c.pageNumber + } + if c.pageSize > 0 { + input.PageSize = c.pageSize + } + + return &input +} + +// printVerbose displays the information returned from the API in a verbose +// format. +func (c *ListCommand) printVerbose(out io.Writer, rs []*fastly.TLSActivation) { + for _, r := range rs { + fmt.Fprintf(out, "\nID: %s\n", r.ID) + + if r.CreatedAt != nil { + fmt.Fprintf(out, "Created at: %s\n", r.CreatedAt) + } + + fmt.Fprintf(out, "\n") + } +} + +// printSummary displays the information returned from the API in a summarised +// format. +func (c *ListCommand) printSummary(out io.Writer, rs []*fastly.TLSActivation) error { + t := text.NewTable(out) + t.AddHeader("ID", "CREATED_AT") + for _, r := range rs { + t.AddLine(r.ID, r.CreatedAt) + } + t.Print() + return nil +} diff --git a/pkg/commands/tls/custom/activation/root.go b/pkg/commands/tls/custom/activation/root.go new file mode 100644 index 000000000..6bd133ff8 --- /dev/null +++ b/pkg/commands/tls/custom/activation/root.go @@ -0,0 +1,31 @@ +package activation + +import ( + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" +) + +// RootCommand is the parent command for all subcommands in this package. +// It should be installed under the primary root command. +type RootCommand struct { + argparser.Base + // no flags +} + +// CommandName is the string to be used to invoke this command. +const CommandName = "activation" + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent argparser.Registerer, g *global.Data) *RootCommand { + var c RootCommand + c.Globals = g + c.CmdClause = parent.Command(CommandName, "Upload and manage TLS activations") + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { + panic("unreachable") +} diff --git a/pkg/commands/tls/custom/activation/update.go b/pkg/commands/tls/custom/activation/update.go new file mode 100644 index 000000000..5a496c4ac --- /dev/null +++ b/pkg/commands/tls/custom/activation/update.go @@ -0,0 +1,58 @@ +package activation + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// NewUpdateCommand returns a usable command registered under the parent. +func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { + var c UpdateCommand + c.CmdClause = parent.Command("update", "Update the certificate used to terminate TLS traffic for the domain associated with this TLS activation") + c.Globals = g + + // Required. + c.CmdClause.Flag("cert-id", "Alphanumeric string identifying a TLS certificate").Required().StringVar(&c.certID) + c.CmdClause.Flag("id", "Alphanumeric string identifying a TLS activation").Required().StringVar(&c.id) + return &c +} + +// UpdateCommand calls the Fastly API to update an appropriate resource. +type UpdateCommand struct { + argparser.Base + + certID string + id string +} + +// Exec invokes the application logic for the command. +func (c *UpdateCommand) Exec(_ io.Reader, out io.Writer) error { + input := c.constructInput() + + r, err := c.Globals.APIClient.UpdateTLSActivation(input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "TLS Activation ID": c.id, + "TLS Activation Certificate ID": c.certID, + }) + return err + } + + text.Success(out, "Updated TLS Activation Certificate '%s' (previously: '%s')", r.Certificate.ID, input.Certificate.ID) + return nil +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *UpdateCommand) constructInput() *fastly.UpdateTLSActivationInput { + var input fastly.UpdateTLSActivationInput + + input.ID = c.id + input.Certificate = &fastly.CustomTLSCertificate{ID: c.certID} + + return &input +} diff --git a/pkg/commands/tls/custom/certificate/certificate_test.go b/pkg/commands/tls/custom/certificate/certificate_test.go new file mode 100644 index 000000000..e779d4f4b --- /dev/null +++ b/pkg/commands/tls/custom/certificate/certificate_test.go @@ -0,0 +1,278 @@ +package certificate_test + +import ( + "fmt" + "testing" + + "github.com/fastly/go-fastly/v10/fastly" + + root "github.com/fastly/cli/pkg/commands/tls/custom" + sub "github.com/fastly/cli/pkg/commands/tls/custom/certificate" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" +) + +const ( + mockResponseID = "123" + mockFieldValue = "example" + validateAPIError = "validate API error" + validateAPISuccess = "validate API success" + validateMissingIDFlag = "validate missing --id flag" +) + +func TestTLSCustomCertCreate(t *testing.T) { + var content string + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --cert-blob and --cert-path flags", + WantError: "neither --cert-path or --cert-blob provided, one must be provided", + }, + { + Name: "validate specifying both --cert-blob and --cert-path flags", + Args: "--cert-blob foo --cert-path bar", + WantError: "cert-path and cert-blob provided, only one can be specified", + }, + { + Name: "validate invalid --cert-path arg", + Args: "--cert-path ............", + WantError: "error reading cert-path", + }, + { + Name: "validate custom cert is submitted", + API: mock.API{ + CreateCustomTLSCertificateFn: func(certInput *fastly.CreateCustomTLSCertificateInput) (*fastly.CustomTLSCertificate, error) { + content = certInput.CertBlob + return &fastly.CustomTLSCertificate{ + ID: mockResponseID, + }, nil + }, + }, + Args: "--cert-path ./testdata/certificate.crt", + WantOutput: fmt.Sprintf("Created TLS Certificate '%s'", mockResponseID), + PathContentFlag: &testutil.PathContentFlag{Flag: "cert-path", Fixture: "certificate.crt", Content: func() string { return content }}, + }, + { + Name: validateAPIError, + API: mock.API{ + CreateCustomTLSCertificateFn: func(certInput *fastly.CreateCustomTLSCertificateInput) (*fastly.CustomTLSCertificate, error) { + content = certInput.CertBlob + return nil, testutil.Err + }, + }, + Args: "--cert-blob example", + WantError: testutil.Err.Error(), + PathContentFlag: &testutil.PathContentFlag{Flag: "cert-path", Fixture: "certificate.crt", Content: func() string { return content }}, + }, + { + Name: validateAPISuccess, + API: mock.API{ + CreateCustomTLSCertificateFn: func(certInput *fastly.CreateCustomTLSCertificateInput) (*fastly.CustomTLSCertificate, error) { + content = certInput.CertBlob + return &fastly.CustomTLSCertificate{ + ID: mockResponseID, + }, nil + }, + }, + Args: "--cert-blob example", + WantOutput: fmt.Sprintf("Created TLS Certificate '%s'", mockResponseID), + PathContentFlag: &testutil.PathContentFlag{Flag: "cert-path", Fixture: "certificate.crt", Content: func() string { return content }}, + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "create"}, scenarios) +} + +func TestTLSCustomCertDelete(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: validateMissingIDFlag, + WantError: "error parsing arguments: required flag --id not provided", + }, + { + Name: validateAPIError, + API: mock.API{ + DeleteCustomTLSCertificateFn: func(_ *fastly.DeleteCustomTLSCertificateInput) error { + return testutil.Err + }, + }, + Args: "--id example", + WantError: testutil.Err.Error(), + }, + { + Name: validateAPISuccess, + API: mock.API{ + DeleteCustomTLSCertificateFn: func(_ *fastly.DeleteCustomTLSCertificateInput) error { + return nil + }, + }, + Args: "--id example", + WantOutput: "Deleted TLS Certificate 'example'", + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "delete"}, scenarios) +} + +func TestTLSCustomCertDescribe(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: validateMissingIDFlag, + WantError: "error parsing arguments: required flag --id not provided", + }, + { + Name: validateAPIError, + API: mock.API{ + GetCustomTLSCertificateFn: func(_ *fastly.GetCustomTLSCertificateInput) (*fastly.CustomTLSCertificate, error) { + return nil, testutil.Err + }, + }, + Args: "--id example", + WantError: testutil.Err.Error(), + }, + { + Name: validateAPISuccess, + API: mock.API{ + GetCustomTLSCertificateFn: func(_ *fastly.GetCustomTLSCertificateInput) (*fastly.CustomTLSCertificate, error) { + t := testutil.Date + return &fastly.CustomTLSCertificate{ + ID: mockResponseID, + IssuedTo: mockFieldValue, + Issuer: mockFieldValue, + Name: mockFieldValue, + Replace: true, + SerialNumber: mockFieldValue, + SignatureAlgorithm: mockFieldValue, + CreatedAt: &t, + UpdatedAt: &t, + }, nil + }, + }, + Args: "--id example", + WantOutput: "\nID: " + mockResponseID + "\nIssued to: " + mockFieldValue + "\nIssuer: " + mockFieldValue + "\nName: " + mockFieldValue + "\nReplace: true\nSerial number: " + mockFieldValue + "\nSignature algorithm: " + mockFieldValue + "\nCreated at: 2021-06-15 23:00:00 +0000 UTC\nUpdated at: 2021-06-15 23:00:00 +0000 UTC\n", + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "describe"}, scenarios) +} + +func TestTLSCustomCertList(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: validateAPIError, + API: mock.API{ + ListCustomTLSCertificatesFn: func(_ *fastly.ListCustomTLSCertificatesInput) ([]*fastly.CustomTLSCertificate, error) { + return nil, testutil.Err + }, + }, + WantError: testutil.Err.Error(), + }, + { + Name: validateAPISuccess, + API: mock.API{ + ListCustomTLSCertificatesFn: func(_ *fastly.ListCustomTLSCertificatesInput) ([]*fastly.CustomTLSCertificate, error) { + t := testutil.Date + return []*fastly.CustomTLSCertificate{ + { + ID: mockResponseID, + IssuedTo: mockFieldValue, + Issuer: mockFieldValue, + Name: mockFieldValue, + Replace: true, + SerialNumber: mockFieldValue, + SignatureAlgorithm: mockFieldValue, + CreatedAt: &t, + UpdatedAt: &t, + }, + }, nil + }, + }, + Args: "--verbose", + WantOutput: "Fastly API endpoint: https://api.fastly.com\nFastly API token provided via config file (profile: user)\n\nID: " + mockResponseID + "\nIssued to: " + mockFieldValue + "\nIssuer: " + mockFieldValue + "\nName: " + mockFieldValue + "\nReplace: true\nSerial number: " + mockFieldValue + "\nSignature algorithm: " + mockFieldValue + "\nCreated at: 2021-06-15 23:00:00 +0000 UTC\nUpdated at: 2021-06-15 23:00:00 +0000 UTC\n", + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "list"}, scenarios) +} + +func TestTLSCustomCertUpdate(t *testing.T) { + var content string + scenarios := []testutil.CLIScenario{ + { + Name: validateMissingIDFlag, + Args: "--cert-blob example", + WantError: "required flag --id not provided", + }, + { + Name: "validate missing --cert-blob and --cert-path flags", + Args: "--id example", + WantError: "neither --cert-path or --cert-blob provided, one must be provided", + }, + { + Name: "validate specifying both --cert-blob and --cert-path flags", + Args: "--id example --cert-blob foo --cert-path bar", + WantError: "cert-path and cert-blob provided, only one can be specified", + }, + { + Name: "validate invalid --cert-path arg", + Args: "--id example --cert-path ............", + WantError: "error reading cert-path", + }, + { + Name: validateAPIError, + API: mock.API{ + UpdateCustomTLSCertificateFn: func(certInput *fastly.UpdateCustomTLSCertificateInput) (*fastly.CustomTLSCertificate, error) { + content = certInput.CertBlob + return nil, testutil.Err + }, + }, + Args: "--cert-blob example --id example", + WantError: testutil.Err.Error(), + PathContentFlag: &testutil.PathContentFlag{Flag: "cert-path", Fixture: "certificate.crt", Content: func() string { return content }}, + }, + { + Name: validateAPISuccess, + API: mock.API{ + UpdateCustomTLSCertificateFn: func(certInput *fastly.UpdateCustomTLSCertificateInput) (*fastly.CustomTLSCertificate, error) { + content = certInput.CertBlob + return &fastly.CustomTLSCertificate{ + ID: mockResponseID, + }, nil + }, + }, + Args: "--cert-blob example --id example", + WantOutput: fmt.Sprintf("Updated TLS Certificate '%s'", mockResponseID), + PathContentFlag: &testutil.PathContentFlag{Flag: "cert-path", Fixture: "certificate.crt", Content: func() string { return content }}, + }, + { + Name: validateAPISuccess + " with --name for different output", + API: mock.API{ + UpdateCustomTLSCertificateFn: func(certInput *fastly.UpdateCustomTLSCertificateInput) (*fastly.CustomTLSCertificate, error) { + content = certInput.CertBlob + return &fastly.CustomTLSCertificate{ + ID: mockResponseID, + Name: "Updated", + }, nil + }, + }, + Args: "--cert-blob example --id example --name example", + WantOutput: "Updated TLS Certificate 'Updated' (previously: 'example')", + PathContentFlag: &testutil.PathContentFlag{Flag: "cert-path", Fixture: "certificate.crt", Content: func() string { return content }}, + }, + { + Name: "validate custom cert is submitted", + API: mock.API{ + UpdateCustomTLSCertificateFn: func(certInput *fastly.UpdateCustomTLSCertificateInput) (*fastly.CustomTLSCertificate, error) { + content = certInput.CertBlob + return &fastly.CustomTLSCertificate{ + ID: mockResponseID, + }, nil + }, + }, + Args: "--id example --cert-path ./testdata/certificate.crt", + WantOutput: "SUCCESS: Updated TLS Certificate '123'", + PathContentFlag: &testutil.PathContentFlag{Flag: "cert-path", Fixture: "certificate.crt", Content: func() string { return content }}, + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "update"}, scenarios) +} diff --git a/pkg/commands/tls/custom/certificate/create.go b/pkg/commands/tls/custom/certificate/create.go new file mode 100644 index 000000000..9a3c3b6b9 --- /dev/null +++ b/pkg/commands/tls/custom/certificate/create.go @@ -0,0 +1,103 @@ +package certificate + +import ( + "fmt" + "io" + "os" + "path/filepath" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// NewCreateCommand returns a usable command registered under the parent. +func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { + var c CreateCommand + c.CmdClause = parent.Command("create", "Create a TLS certificate").Alias("add") + c.Globals = g + + // Required + // cert-blob and cert-path are mutually exclusive. One is required. + c.CmdClause.Flag("cert-blob", "The PEM-formatted certificate blob, mutually exclusive with --cert-path").StringVar(&c.certBlob) + c.CmdClause.Flag("cert-path", "Filepath to a PEM-formatted certificate, mutually exclusive with --cert-blob").StringVar(&c.certPath) + + // Optional. + c.CmdClause.Flag("id", "Alphanumeric string identifying a TLS certificate").StringVar(&c.id) + c.CmdClause.Flag("name", "A customizable name for your certificate. Defaults to the certificate's Common Name or first Subject Alternative Names (SAN) entry").StringVar(&c.name) + + return &c +} + +// CreateCommand calls the Fastly API to create an appropriate resource. +type CreateCommand struct { + argparser.Base + + certBlob string + certPath string + id string + name string +} + +// Exec invokes the application logic for the command. +func (c *CreateCommand) Exec(_ io.Reader, out io.Writer) error { + input, err := c.constructInput() + if err != nil { + return err + } + + r, err := c.Globals.APIClient.CreateCustomTLSCertificate(input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "TLS Certificate ID": c.id, + "TLS Certificate Name": c.name, + }) + return err + } + + text.Success(out, "Created TLS Certificate '%s'", r.ID) + return nil +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *CreateCommand) constructInput() (*fastly.CreateCustomTLSCertificateInput, error) { + var input fastly.CreateCustomTLSCertificateInput + + if c.certPath == "" && c.certBlob == "" { + return nil, fmt.Errorf("neither --cert-path or --cert-blob provided, one must be provided") + } + + if c.certPath != "" && c.certBlob != "" { + return nil, fmt.Errorf("cert-path and cert-blob provided, only one can be specified") + } + + if c.id != "" { + input.ID = c.id + } + + if c.certBlob != "" { + input.CertBlob = c.certBlob + } + + if c.certPath != "" { + path, err := filepath.Abs(c.certPath) + if err != nil { + return nil, fmt.Errorf("error parsing cert-path '%s': %q", c.certPath, err) + } + + data, err := os.ReadFile(path) // #nosec + if err != nil { + return nil, fmt.Errorf("error reading cert-path '%s': %q", c.certPath, err) + } + + input.CertBlob = string(data) + } + + if c.name != "" { + input.Name = c.name + } + + return &input, nil +} diff --git a/pkg/commands/tls/custom/certificate/delete.go b/pkg/commands/tls/custom/certificate/delete.go new file mode 100644 index 000000000..14b255b94 --- /dev/null +++ b/pkg/commands/tls/custom/certificate/delete.go @@ -0,0 +1,55 @@ +package certificate + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// NewDeleteCommand returns a usable command registered under the parent. +func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { + var c DeleteCommand + c.CmdClause = parent.Command("delete", "Destroy a TLS certificate. TLS certificates already enabled for a domain cannot be destroyed").Alias("remove") + c.Globals = g + + // Required. + c.CmdClause.Flag("id", "Alphanumeric string identifying a TLS certificate").Required().StringVar(&c.id) + + return &c +} + +// DeleteCommand calls the Fastly API to delete an appropriate resource. +type DeleteCommand struct { + argparser.Base + + id string +} + +// Exec invokes the application logic for the command. +func (c *DeleteCommand) Exec(_ io.Reader, out io.Writer) error { + input := c.constructInput() + + err := c.Globals.APIClient.DeleteCustomTLSCertificate(input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "TLS Certificate ID": c.id, + }) + return err + } + + text.Success(out, "Deleted TLS Certificate '%s'", c.id) + return nil +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *DeleteCommand) constructInput() *fastly.DeleteCustomTLSCertificateInput { + var input fastly.DeleteCustomTLSCertificateInput + + input.ID = c.id + + return &input +} diff --git a/pkg/commands/tls/custom/certificate/describe.go b/pkg/commands/tls/custom/certificate/describe.go new file mode 100644 index 000000000..637c1b449 --- /dev/null +++ b/pkg/commands/tls/custom/certificate/describe.go @@ -0,0 +1,95 @@ +package certificate + +import ( + "fmt" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" +) + +// NewDescribeCommand returns a usable command registered under the parent. +func NewDescribeCommand(parent argparser.Registerer, g *global.Data) *DescribeCommand { + var c DescribeCommand + c.CmdClause = parent.Command("describe", "Show a TLS certificate").Alias("get") + c.Globals = g + + // Required. + c.CmdClause.Flag("id", "Alphanumeric string identifying a TLS certificate").Required().StringVar(&c.id) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + + return &c +} + +// DescribeCommand calls the Fastly API to describe an appropriate resource. +type DescribeCommand struct { + argparser.Base + argparser.JSONOutput + + id string +} + +// Exec invokes the application logic for the command. +func (c *DescribeCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + input := c.constructInput() + + o, err := c.Globals.APIClient.GetCustomTLSCertificate(input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "TLS Certificate ID": c.id, + }) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + return c.print(out, o) +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *DescribeCommand) constructInput() *fastly.GetCustomTLSCertificateInput { + var input fastly.GetCustomTLSCertificateInput + + input.ID = c.id + + return &input +} + +// print displays the information returned from the API. +func (c *DescribeCommand) print(out io.Writer, r *fastly.CustomTLSCertificate) error { + fmt.Fprintf(out, "\nID: %s\n", r.ID) + fmt.Fprintf(out, "Issued to: %s\n", r.IssuedTo) + fmt.Fprintf(out, "Issuer: %s\n", r.Issuer) + fmt.Fprintf(out, "Name: %s\n", r.Name) + + if r.NotAfter != nil { + fmt.Fprintf(out, "Not after: %s\n", r.NotAfter) + } + if r.NotBefore != nil { + fmt.Fprintf(out, "Not before: %s\n", r.NotBefore) + } + + fmt.Fprintf(out, "Replace: %t\n", r.Replace) + fmt.Fprintf(out, "Serial number: %s\n", r.SerialNumber) + fmt.Fprintf(out, "Signature algorithm: %s\n", r.SignatureAlgorithm) + + if r.CreatedAt != nil { + fmt.Fprintf(out, "Created at: %s\n", r.CreatedAt) + } + if r.UpdatedAt != nil { + fmt.Fprintf(out, "Updated at: %s\n", r.UpdatedAt) + } + + return nil +} diff --git a/pkg/commands/tls/custom/certificate/doc.go b/pkg/commands/tls/custom/certificate/doc.go new file mode 100644 index 000000000..f2c440389 --- /dev/null +++ b/pkg/commands/tls/custom/certificate/doc.go @@ -0,0 +1,3 @@ +// Package certificate contains commands to inspect and manipulate Fastly +// custom TLS certificates. +package certificate diff --git a/pkg/commands/tls/custom/certificate/list.go b/pkg/commands/tls/custom/certificate/list.go new file mode 100644 index 000000000..c6587568c --- /dev/null +++ b/pkg/commands/tls/custom/certificate/list.go @@ -0,0 +1,151 @@ +package certificate + +import ( + "fmt" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +const emptyString = "" + +// NewListCommand returns a usable command registered under the parent. +func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { + var c ListCommand + c.CmdClause = parent.Command("list", "List all TLS certificates") + c.Globals = g + + // Optional. + c.CmdClause.Flag("filter-not-after", "Limit the returned certificates to those that expire prior to the specified date in UTC").StringVar(&c.filterNotAfter) + c.CmdClause.Flag("filter-domain", "Limit the returned certificates to those that include the specific domain").StringVar(&c.filterTLSDomainID) + c.CmdClause.Flag("include", "Include related objects (comma-separated values)").HintOptions("tls_activations").EnumVar(&c.include, "tls_activations") + c.RegisterFlagBool(c.JSONFlag()) // --json + c.CmdClause.Flag("page", "Page number of data set to fetch").IntVar(&c.pageNumber) + c.CmdClause.Flag("per-page", "Number of records per page").IntVar(&c.pageSize) + c.CmdClause.Flag("sort", "The order in which to list the results by creation date").StringVar(&c.sort) + + return &c +} + +// ListCommand calls the Fastly API to list appropriate resources. +type ListCommand struct { + argparser.Base + argparser.JSONOutput + + filterNotAfter string + filterTLSDomainID string + include string + pageNumber int + pageSize int + sort string +} + +// Exec invokes the application logic for the command. +func (c *ListCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + input := c.constructInput() + + o, err := c.Globals.APIClient.ListCustomTLSCertificates(input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Filter Not After": c.filterNotAfter, + "Filter TLS Domain ID": c.filterTLSDomainID, + "Include": c.include, + "Page Number": c.pageNumber, + "Page Size": c.pageSize, + "Sort": c.sort, + }) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + if c.Globals.Verbose() { + printVerbose(out, o) + } else { + err = c.printSummary(out, o) + if err != nil { + return err + } + } + return nil +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *ListCommand) constructInput() *fastly.ListCustomTLSCertificatesInput { + var input fastly.ListCustomTLSCertificatesInput + + if c.filterNotAfter != emptyString { + input.FilterNotAfter = c.filterNotAfter + } + if c.filterTLSDomainID != emptyString { + input.FilterTLSDomainsID = c.filterTLSDomainID + } + if c.include != emptyString { + input.Include = c.include + } + if c.pageNumber > 0 { + input.PageNumber = c.pageNumber + } + if c.pageSize > 0 { + input.PageSize = c.pageSize + } + if c.sort != "" { + input.Sort = c.sort + } + + return &input +} + +// printVerbose displays the information returned from the API in a verbose +// format. +func printVerbose(out io.Writer, rs []*fastly.CustomTLSCertificate) { + for _, r := range rs { + fmt.Fprintf(out, "ID: %s\n", r.ID) + fmt.Fprintf(out, "Issued to: %s\n", r.IssuedTo) + fmt.Fprintf(out, "Issuer: %s\n", r.Issuer) + fmt.Fprintf(out, "Name: %s\n", r.Name) + + if r.NotAfter != nil { + fmt.Fprintf(out, "Not after: %s\n", r.NotAfter) + } + if r.NotBefore != nil { + fmt.Fprintf(out, "Not before: %s\n", r.NotBefore) + } + + fmt.Fprintf(out, "Replace: %t\n", r.Replace) + fmt.Fprintf(out, "Serial number: %s\n", r.SerialNumber) + fmt.Fprintf(out, "Signature algorithm: %s\n", r.SignatureAlgorithm) + + if r.CreatedAt != nil { + fmt.Fprintf(out, "Created at: %s\n", r.CreatedAt) + } + if r.UpdatedAt != nil { + fmt.Fprintf(out, "Updated at: %s\n", r.UpdatedAt) + } + + fmt.Fprintf(out, "\n") + } +} + +// printSummary displays the information returned from the API in a summarised +// format. +func (c *ListCommand) printSummary(out io.Writer, rs []*fastly.CustomTLSCertificate) error { + t := text.NewTable(out) + t.AddHeader("ID", "ISSUED TO", "NAME", "REPLACE", "SIGNATURE ALGORITHM") + for _, r := range rs { + t.AddLine(r.ID, r.IssuedTo, r.Name, r.Replace, r.SignatureAlgorithm) + } + t.Print() + return nil +} diff --git a/pkg/commands/tls/custom/certificate/root.go b/pkg/commands/tls/custom/certificate/root.go new file mode 100644 index 000000000..159255b09 --- /dev/null +++ b/pkg/commands/tls/custom/certificate/root.go @@ -0,0 +1,31 @@ +package certificate + +import ( + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" +) + +// RootCommand is the parent command for all subcommands in this package. +// It should be installed under the primary root command. +type RootCommand struct { + argparser.Base + // no flags +} + +// CommandName is the string to be used to invoke this command. +const CommandName = "certificate" + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent argparser.Registerer, g *global.Data) *RootCommand { + var c RootCommand + c.Globals = g + c.CmdClause = parent.Command(CommandName, "Upload and manage TLS certificates") + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { + panic("unreachable") +} diff --git a/pkg/commands/tls/custom/certificate/testdata/certificate.crt b/pkg/commands/tls/custom/certificate/testdata/certificate.crt new file mode 100644 index 000000000..4209c82c1 --- /dev/null +++ b/pkg/commands/tls/custom/certificate/testdata/certificate.crt @@ -0,0 +1 @@ +this is a fake cert \ No newline at end of file diff --git a/pkg/commands/tls/custom/certificate/update.go b/pkg/commands/tls/custom/certificate/update.go new file mode 100644 index 000000000..6ec0ebdff --- /dev/null +++ b/pkg/commands/tls/custom/certificate/update.go @@ -0,0 +1,104 @@ +package certificate + +import ( + "fmt" + "io" + "os" + "path/filepath" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// NewUpdateCommand returns a usable command registered under the parent. +func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { + var c UpdateCommand + c.CmdClause = parent.Command("update", "Replace a TLS certificate with a newly reissued TLS certificate, or update a TLS certificate's name") + c.Globals = g + + // Required + // cert-blob and cert-path are mutually exclusive. One is required. + c.CmdClause.Flag("cert-blob", "The PEM-formatted certificate blob, mutually exclusive with --cert-path").StringVar(&c.certBlob) + c.CmdClause.Flag("cert-path", "Filepath to a PEM-formatted certificate, mutually exclusive with --cert-blob").StringVar(&c.certPath) + c.CmdClause.Flag("id", "Alphanumeric string identifying a TLS certificate").Required().StringVar(&c.id) + + // Optional. + c.CmdClause.Flag("name", "A customizable name for your certificate. Defaults to the certificate's Common Name or first Subject Alternative Names (SAN) entry").StringVar(&c.name) + return &c +} + +// UpdateCommand calls the Fastly API to update an appropriate resource. +type UpdateCommand struct { + argparser.Base + + certBlob string + certPath string + id string + name string +} + +// Exec invokes the application logic for the command. +func (c *UpdateCommand) Exec(_ io.Reader, out io.Writer) error { + input, err := c.constructInput() + if err != nil { + return err + } + + r, err := c.Globals.APIClient.UpdateCustomTLSCertificate(input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "TLS Certificate ID": c.id, + "TLS Certificate Name": c.name, + }) + return err + } + + if c.name != "" { + text.Success(out, "Updated TLS Certificate '%s' (previously: '%s')", r.Name, input.Name) + } else { + text.Success(out, "Updated TLS Certificate '%s'", r.ID) + } + return nil +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *UpdateCommand) constructInput() (*fastly.UpdateCustomTLSCertificateInput, error) { + var input fastly.UpdateCustomTLSCertificateInput + + if c.certPath == "" && c.certBlob == "" { + return nil, fmt.Errorf("neither --cert-path or --cert-blob provided, one must be provided") + } + + if c.certPath != "" && c.certBlob != "" { + return nil, fmt.Errorf("cert-path and cert-blob provided, only one can be specified") + } + + input.ID = c.id + + if c.certBlob != "" { + input.CertBlob = c.certBlob + } + + if c.certPath != "" { + path, err := filepath.Abs(c.certPath) + if err != nil { + return nil, fmt.Errorf("error parsing cert-path '%s': %q", c.certPath, err) + } + + data, err := os.ReadFile(path) // #nosec + if err != nil { + return nil, fmt.Errorf("error reading cert-path '%s': %q", c.certPath, err) + } + + input.CertBlob = string(data) + } + + if c.name != "" { + input.Name = c.name + } + + return &input, nil +} diff --git a/pkg/commands/tls/custom/doc.go b/pkg/commands/tls/custom/doc.go new file mode 100644 index 000000000..1f01f35d0 --- /dev/null +++ b/pkg/commands/tls/custom/doc.go @@ -0,0 +1,3 @@ +// Package custom contains commands to inspect and manipulate Fastly custom TLS +// certificates. +package custom diff --git a/pkg/commands/tls/custom/domain/doc.go b/pkg/commands/tls/custom/domain/doc.go new file mode 100644 index 000000000..f44b9b031 --- /dev/null +++ b/pkg/commands/tls/custom/domain/doc.go @@ -0,0 +1,3 @@ +// Package domain contains commands to inspect and manipulate Fastly custom TLS +// domains. +package domain diff --git a/pkg/commands/tls/custom/domain/domain_test.go b/pkg/commands/tls/custom/domain/domain_test.go new file mode 100644 index 000000000..d7bf3ab1b --- /dev/null +++ b/pkg/commands/tls/custom/domain/domain_test.go @@ -0,0 +1,49 @@ +package domain_test + +import ( + "testing" + + "github.com/fastly/go-fastly/v10/fastly" + + root "github.com/fastly/cli/pkg/commands/tls/custom" + sub "github.com/fastly/cli/pkg/commands/tls/custom/domain" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" +) + +const ( + mockResponseID = "123" + validateAPIError = "validate API error" + validateAPISuccess = "validate API success" +) + +func TestList(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: validateAPIError, + API: mock.API{ + ListTLSDomainsFn: func(_ *fastly.ListTLSDomainsInput) ([]*fastly.TLSDomain, error) { + return nil, testutil.Err + }, + }, + WantError: testutil.Err.Error(), + }, + { + Name: validateAPISuccess, + API: mock.API{ + ListTLSDomainsFn: func(_ *fastly.ListTLSDomainsInput) ([]*fastly.TLSDomain, error) { + return []*fastly.TLSDomain{ + { + ID: mockResponseID, + Type: "example", + }, + }, nil + }, + }, + Args: "--verbose", + WantOutput: "\nID: " + mockResponseID + "\nType: example\n\n", + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "list"}, scenarios) +} diff --git a/pkg/commands/tls/custom/domain/list.go b/pkg/commands/tls/custom/domain/list.go new file mode 100644 index 000000000..1f1d9c955 --- /dev/null +++ b/pkg/commands/tls/custom/domain/list.go @@ -0,0 +1,136 @@ +package domain + +import ( + "fmt" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +const emptyString = "" + +// NewListCommand returns a usable command registered under the parent. +func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { + var c ListCommand + c.CmdClause = parent.Command("list", "List all TLS domains") + c.Globals = g + + // Optional. + c.CmdClause.Flag("filter-cert", "Limit the returned domains to those listed in the given TLS certificate's SAN list").StringVar(&c.filterTLSCertsID) + c.CmdClause.Flag("filter-in-use", "Limit the returned domains to those currently using Fastly to terminate TLS with SNI").Action(c.filterInUse.Set).BoolVar(&c.filterInUse.Value) + c.CmdClause.Flag("filter-subscription", "Limit the returned domains to those for a given TLS subscription").StringVar(&c.filterTLSSubsID) + c.CmdClause.Flag("include", "Include related objects (comma-separated values)").HintOptions("tls_activations").EnumVar(&c.include, "tls_activations") + c.RegisterFlagBool(c.JSONFlag()) // --json + c.CmdClause.Flag("page", "Page number of data set to fetch").IntVar(&c.pageNumber) + c.CmdClause.Flag("per-page", "Number of records per page").IntVar(&c.pageSize) + c.CmdClause.Flag("sort", "The order in which to list the results by creation date").StringVar(&c.sort) + + return &c +} + +// ListCommand calls the Fastly API to list appropriate resources. +type ListCommand struct { + argparser.Base + argparser.JSONOutput + + filterInUse argparser.OptionalBool + filterTLSCertsID string + filterTLSSubsID string + include string + pageNumber int + pageSize int + sort string +} + +// Exec invokes the application logic for the command. +func (c *ListCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + input := c.constructInput() + + o, err := c.Globals.APIClient.ListTLSDomains(input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Filter In Use": c.filterInUse, + "Filter TLS Certificates": c.filterTLSCertsID, + "Filter TLS Subscriptions": c.filterTLSSubsID, + "Include": c.include, + "Page Number": c.pageNumber, + "Page Size": c.pageSize, + "Sort": c.sort, + }) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + if c.Globals.Verbose() { + printVerbose(out, o) + } else { + err = c.printSummary(out, o) + if err != nil { + return err + } + } + return nil +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *ListCommand) constructInput() *fastly.ListTLSDomainsInput { + var input fastly.ListTLSDomainsInput + + if c.filterInUse.WasSet { + input.FilterInUse = &c.filterInUse.Value + } + if c.filterTLSCertsID != emptyString { + input.FilterTLSCertificateID = c.filterTLSCertsID + } + if c.filterTLSSubsID != emptyString { + input.FilterTLSSubscriptionID = c.filterTLSSubsID + } + if c.include != emptyString { + input.Include = c.include + } + if c.pageNumber > 0 { + input.PageNumber = c.pageNumber + } + if c.pageSize > 0 { + input.PageSize = c.pageSize + } + if c.sort != "" { + input.Sort = c.sort + } + + return &input +} + +// printVerbose displays the information returned from the API in a verbose +// format. +func printVerbose(out io.Writer, rs []*fastly.TLSDomain) { + for _, r := range rs { + fmt.Fprintf(out, "\nID: %s\n", r.ID) + fmt.Fprintf(out, "Type: %s\n", r.Type) + fmt.Fprintf(out, "\n") + } +} + +// printSummary displays the information returned from the API in a summarised +// format. +func (c *ListCommand) printSummary(out io.Writer, rs []*fastly.TLSDomain) error { + t := text.NewTable(out) + t.AddHeader("ID", "TYPE") + for _, r := range rs { + t.AddLine(r.ID, r.Type) + } + t.Print() + return nil +} diff --git a/pkg/commands/tls/custom/domain/root.go b/pkg/commands/tls/custom/domain/root.go new file mode 100644 index 000000000..d4ceb332e --- /dev/null +++ b/pkg/commands/tls/custom/domain/root.go @@ -0,0 +1,31 @@ +package domain + +import ( + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" +) + +// RootCommand is the parent command for all subcommands in this package. +// It should be installed under the primary root command. +type RootCommand struct { + argparser.Base + // no flags +} + +// CommandName is the string to be used to invoke this command. +const CommandName = "domain" + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent argparser.Registerer, g *global.Data) *RootCommand { + var c RootCommand + c.Globals = g + c.CmdClause = parent.Command(CommandName, "Manage TLS domains") + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { + panic("unreachable") +} diff --git a/pkg/commands/tls/custom/privatekey/create.go b/pkg/commands/tls/custom/privatekey/create.go new file mode 100644 index 000000000..52a385d5c --- /dev/null +++ b/pkg/commands/tls/custom/privatekey/create.go @@ -0,0 +1,91 @@ +package privatekey + +import ( + "fmt" + "io" + "os" + "path/filepath" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// NewCreateCommand returns a usable command registered under the parent. +func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { + var c CreateCommand + c.CmdClause = parent.Command("create", "Create a TLS private key").Alias("add") + c.Globals = g + + // Required. + c.CmdClause.Flag("key", "The contents of the private key. Must be a PEM-formatted key, mutually exclusive with --key-path").StringVar(&c.key) + c.CmdClause.Flag("key-path", "Filepath to a PEM-formatted key, mutually exclusive with --key").StringVar(&c.keyPath) + c.CmdClause.Flag("name", "A customizable name for your private key").Required().StringVar(&c.name) + + return &c +} + +// CreateCommand calls the Fastly API to create an appropriate resource. +type CreateCommand struct { + argparser.Base + + key string + keyPath string + name string +} + +// Exec invokes the application logic for the command. +func (c *CreateCommand) Exec(_ io.Reader, out io.Writer) error { + input, err := c.constructInput() + if err != nil { + return err + } + + r, err := c.Globals.APIClient.CreatePrivateKey(input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Private Key Name": c.name, + }) + return err + } + + text.Success(out, "Created TLS Private Key '%s'", r.Name) + return nil +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *CreateCommand) constructInput() (*fastly.CreatePrivateKeyInput, error) { + var input fastly.CreatePrivateKeyInput + + if c.keyPath == "" && c.key == "" { + return nil, fmt.Errorf("neither --key-path or --key provided, one must be provided") + } + + if c.keyPath != "" && c.key != "" { + return nil, fmt.Errorf("--key-path and --key provided, only one can be specified") + } + + if c.key != "" { + input.Key = c.key + } + + if c.keyPath != "" { + path, err := filepath.Abs(c.keyPath) + if err != nil { + return nil, fmt.Errorf("error parsing key-path '%s': %q", c.keyPath, err) + } + + data, err := os.ReadFile(path) // #nosec + if err != nil { + return nil, fmt.Errorf("error reading key-path '%s': %q", c.keyPath, err) + } + + input.Key = string(data) + } + + input.Name = c.name + + return &input, nil +} diff --git a/pkg/commands/tls/custom/privatekey/delete.go b/pkg/commands/tls/custom/privatekey/delete.go new file mode 100644 index 000000000..1e0e6bbda --- /dev/null +++ b/pkg/commands/tls/custom/privatekey/delete.go @@ -0,0 +1,55 @@ +package privatekey + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// NewDeleteCommand returns a usable command registered under the parent. +func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { + var c DeleteCommand + c.CmdClause = parent.Command("delete", "Destroy a TLS private key. Only private keys not already matched to any certificates can be deleted").Alias("remove") + c.Globals = g + + // Required. + c.CmdClause.Flag("id", "Alphanumeric string identifying a private Key").Required().StringVar(&c.id) + + return &c +} + +// DeleteCommand calls the Fastly API to delete an appropriate resource. +type DeleteCommand struct { + argparser.Base + + id string +} + +// Exec invokes the application logic for the command. +func (c *DeleteCommand) Exec(_ io.Reader, out io.Writer) error { + input := c.constructInput() + + err := c.Globals.APIClient.DeletePrivateKey(input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "TLS Private Key ID": c.id, + }) + return err + } + + text.Success(out, "Deleted TLS Private Key '%s'", c.id) + return nil +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *DeleteCommand) constructInput() *fastly.DeletePrivateKeyInput { + var input fastly.DeletePrivateKeyInput + + input.ID = c.id + + return &input +} diff --git a/pkg/commands/tls/custom/privatekey/describe.go b/pkg/commands/tls/custom/privatekey/describe.go new file mode 100644 index 000000000..b38a4b4e5 --- /dev/null +++ b/pkg/commands/tls/custom/privatekey/describe.go @@ -0,0 +1,84 @@ +package privatekey + +import ( + "fmt" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" +) + +// NewDescribeCommand returns a usable command registered under the parent. +func NewDescribeCommand(parent argparser.Registerer, g *global.Data) *DescribeCommand { + var c DescribeCommand + c.CmdClause = parent.Command("describe", "Show a TLS private key").Alias("get") + c.Globals = g + + // Required. + c.CmdClause.Flag("id", "Alphanumeric string identifying a private Key").Required().StringVar(&c.id) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + + return &c +} + +// DescribeCommand calls the Fastly API to describe an appropriate resource. +type DescribeCommand struct { + argparser.Base + argparser.JSONOutput + + id string +} + +// Exec invokes the application logic for the command. +func (c *DescribeCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + input := c.constructInput() + + o, err := c.Globals.APIClient.GetPrivateKey(input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "TLS Certificate ID": c.id, + }) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + return c.print(out, o) +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *DescribeCommand) constructInput() *fastly.GetPrivateKeyInput { + var input fastly.GetPrivateKeyInput + + input.ID = c.id + + return &input +} + +// print displays the information returned from the API. +func (c *DescribeCommand) print(out io.Writer, r *fastly.PrivateKey) error { + fmt.Fprintf(out, "\nID: %s\n", r.ID) + fmt.Fprintf(out, "Name: %s\n", r.Name) + fmt.Fprintf(out, "Key Length: %d\n", r.KeyLength) + fmt.Fprintf(out, "Key Type: %s\n", r.KeyType) + fmt.Fprintf(out, "Public Key SHA1: %s\n", r.PublicKeySHA1) + + if r.CreatedAt != nil { + fmt.Fprintf(out, "Created at: %s\n", r.CreatedAt) + } + + fmt.Fprintf(out, "Replace: %t\n", r.Replace) + + return nil +} diff --git a/pkg/commands/tls/custom/privatekey/doc.go b/pkg/commands/tls/custom/privatekey/doc.go new file mode 100644 index 000000000..b2f258db9 --- /dev/null +++ b/pkg/commands/tls/custom/privatekey/doc.go @@ -0,0 +1,3 @@ +// Package privatekey contains commands to inspect and manipulate Fastly custom +// TLS private keys. +package privatekey diff --git a/pkg/commands/tls/custom/privatekey/list.go b/pkg/commands/tls/custom/privatekey/list.go new file mode 100644 index 000000000..56f912ac4 --- /dev/null +++ b/pkg/commands/tls/custom/privatekey/list.go @@ -0,0 +1,119 @@ +package privatekey + +import ( + "fmt" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// NewListCommand returns a usable command registered under the parent. +func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { + var c ListCommand + c.CmdClause = parent.Command("list", "List all TLS private keys") + c.Globals = g + + // Optional. + c.CmdClause.Flag("filter-in-use", "Limit the returned keys to those without any matching TLS certificates").HintOptions("false").EnumVar(&c.filterInUse, "false") + c.RegisterFlagBool(c.JSONFlag()) // --json + c.CmdClause.Flag("page", "Page number of data set to fetch").IntVar(&c.pageNumber) + c.CmdClause.Flag("per-page", "Number of records per page").IntVar(&c.pageSize) + + return &c +} + +// ListCommand calls the Fastly API to list appropriate resources. +type ListCommand struct { + argparser.Base + argparser.JSONOutput + + filterInUse string + pageNumber int + pageSize int +} + +// Exec invokes the application logic for the command. +func (c *ListCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + input := c.constructInput() + + o, err := c.Globals.APIClient.ListPrivateKeys(input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Filter In Use": c.filterInUse, + "Page Number": c.pageNumber, + "Page Size": c.pageSize, + }) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + if c.Globals.Verbose() { + printVerbose(out, o) + } else { + err = c.printSummary(out, o) + if err != nil { + return err + } + } + return nil +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *ListCommand) constructInput() *fastly.ListPrivateKeysInput { + var input fastly.ListPrivateKeysInput + + if c.filterInUse != "" { + input.FilterInUse = c.filterInUse + } + if c.pageNumber > 0 { + input.PageNumber = c.pageNumber + } + if c.pageSize > 0 { + input.PageSize = c.pageSize + } + + return &input +} + +// printVerbose displays the information returned from the API in a verbose +// format. +func printVerbose(out io.Writer, rs []*fastly.PrivateKey) { + for _, r := range rs { + fmt.Fprintf(out, "\nID: %s\n", r.ID) + fmt.Fprintf(out, "Name: %s\n", r.Name) + fmt.Fprintf(out, "Key Length: %d\n", r.KeyLength) + fmt.Fprintf(out, "Key Type: %s\n", r.KeyType) + fmt.Fprintf(out, "Public Key SHA1: %s\n", r.PublicKeySHA1) + + if r.CreatedAt != nil { + fmt.Fprintf(out, "Created at: %s\n", r.CreatedAt) + } + + fmt.Fprintf(out, "Replace: %t\n", r.Replace) + fmt.Fprintf(out, "\n") + } +} + +// printSummary displays the information returned from the API in a summarised +// format. +func (c *ListCommand) printSummary(out io.Writer, rs []*fastly.PrivateKey) error { + t := text.NewTable(out) + t.AddHeader("ID", "NAME", "KEY LENGTH", "KEY TYPE", "PUBLIC KEY SHA1", "REPLACE") + for _, r := range rs { + t.AddLine(r.ID, r.Name, r.KeyLength, r.KeyType, r.PublicKeySHA1, r.Replace) + } + t.Print() + return nil +} diff --git a/pkg/commands/tls/custom/privatekey/privatekey_test.go b/pkg/commands/tls/custom/privatekey/privatekey_test.go new file mode 100644 index 000000000..585f864fc --- /dev/null +++ b/pkg/commands/tls/custom/privatekey/privatekey_test.go @@ -0,0 +1,197 @@ +package privatekey_test + +import ( + "testing" + + "github.com/fastly/go-fastly/v10/fastly" + + root "github.com/fastly/cli/pkg/commands/tls/custom" + sub "github.com/fastly/cli/pkg/commands/tls/custom/privatekey" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" +) + +const ( + mockFieldValue = "example" + mockKeyLength = 123 + mockResponseID = "123" + validateAPIError = "validate API error" + validateAPISuccess = "validate API success" + validateMissingIDFlag = "validate missing --id flag" +) + +func TestTLSCustomPrivateKeyCreate(t *testing.T) { + var content string + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --key and --key-path flags", + Args: "--name example", + WantError: "neither --key-path or --key provided, one must be provided", + }, + { + Name: "validate using both --key and --key-path flags", + Args: "--name example --key example --key-path foobar", + WantError: "--key-path and --key provided, only one can be specified", + }, + { + Name: "validate missing --name flag", + Args: "--key example", + WantError: "required flag --name not provided", + }, + { + Name: validateAPIError, + API: mock.API{ + CreatePrivateKeyFn: func(i *fastly.CreatePrivateKeyInput) (*fastly.PrivateKey, error) { + content = i.Key + return nil, testutil.Err + }, + }, + Args: "--key example --name example", + WantError: testutil.Err.Error(), + PathContentFlag: &testutil.PathContentFlag{Flag: "key-path", Fixture: "testkey.pem", Content: func() string { return content }}, + }, + { + Name: validateAPISuccess, + API: mock.API{ + CreatePrivateKeyFn: func(i *fastly.CreatePrivateKeyInput) (*fastly.PrivateKey, error) { + content = i.Key + return &fastly.PrivateKey{ + ID: mockResponseID, + Name: i.Name, + }, nil + }, + }, + Args: "--key example --name example", + WantOutput: "Created TLS Private Key 'example'", + PathContentFlag: &testutil.PathContentFlag{Flag: "key-path", Fixture: "testkey.pem", Content: func() string { return content }}, + }, + { + Name: "validate custom key is submitted", + API: mock.API{ + CreatePrivateKeyFn: func(i *fastly.CreatePrivateKeyInput) (*fastly.PrivateKey, error) { + content = i.Key + return &fastly.PrivateKey{ + ID: mockResponseID, + Name: i.Name, + }, nil + }, + }, + Args: "--name example --key-path ./testdata/testkey.pem", + WantOutput: "Created TLS Private Key 'example'", + PathContentFlag: &testutil.PathContentFlag{Flag: "key-path", Fixture: "testkey.pem", Content: func() string { return content }}, + }, + { + Name: "validate invalid --key-path arg", + Args: "--name example --key-path ............", + WantError: "error reading key-path", + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "create"}, scenarios) +} + +func TestTLSCustomPrivateKeyDelete(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: validateMissingIDFlag, + WantError: "error parsing arguments: required flag --id not provided", + }, + { + Name: validateAPIError, + API: mock.API{ + DeletePrivateKeyFn: func(_ *fastly.DeletePrivateKeyInput) error { + return testutil.Err + }, + }, + Args: "--id example", + WantError: testutil.Err.Error(), + }, + { + Name: validateAPISuccess, + API: mock.API{ + DeletePrivateKeyFn: func(_ *fastly.DeletePrivateKeyInput) error { + return nil + }, + }, + Args: "--id example", + WantOutput: "Deleted TLS Private Key 'example'", + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "delete"}, scenarios) +} + +func TestTLSCustomPrivateKeyDescribe(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: validateMissingIDFlag, + WantError: "error parsing arguments: required flag --id not provided", + }, + { + Name: validateAPIError, + API: mock.API{ + GetPrivateKeyFn: func(_ *fastly.GetPrivateKeyInput) (*fastly.PrivateKey, error) { + return nil, testutil.Err + }, + }, + Args: "--id example", + WantError: testutil.Err.Error(), + }, + { + Name: validateAPISuccess, + API: mock.API{ + GetPrivateKeyFn: func(_ *fastly.GetPrivateKeyInput) (*fastly.PrivateKey, error) { + t := testutil.Date + return &fastly.PrivateKey{ + ID: mockResponseID, + Name: mockFieldValue, + KeyLength: mockKeyLength, + KeyType: mockFieldValue, + PublicKeySHA1: mockFieldValue, + CreatedAt: &t, + }, nil + }, + }, + Args: "--id example", + WantOutput: "\nID: " + mockResponseID + "\nName: example\nKey Length: 123\nKey Type: example\nPublic Key SHA1: example\nCreated at: 2021-06-15 23:00:00 +0000 UTC\nReplace: false\n", + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "describe"}, scenarios) +} + +func TestTLSCustomPrivateKeyList(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: validateAPIError, + API: mock.API{ + ListPrivateKeysFn: func(_ *fastly.ListPrivateKeysInput) ([]*fastly.PrivateKey, error) { + return nil, testutil.Err + }, + }, + WantError: testutil.Err.Error(), + }, + { + Name: validateAPISuccess, + API: mock.API{ + ListPrivateKeysFn: func(_ *fastly.ListPrivateKeysInput) ([]*fastly.PrivateKey, error) { + t := testutil.Date + return []*fastly.PrivateKey{ + { + ID: mockResponseID, + Name: mockFieldValue, + KeyLength: mockKeyLength, + KeyType: mockFieldValue, + PublicKeySHA1: mockFieldValue, + CreatedAt: &t, + }, + }, nil + }, + }, + Args: "--verbose", + WantOutput: "\nID: " + mockResponseID + "\nName: example\nKey Length: 123\nKey Type: example\nPublic Key SHA1: example\nCreated at: 2021-06-15 23:00:00 +0000 UTC\nReplace: false\n", + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "list"}, scenarios) +} diff --git a/pkg/commands/tls/custom/privatekey/root.go b/pkg/commands/tls/custom/privatekey/root.go new file mode 100644 index 000000000..c910fb2d3 --- /dev/null +++ b/pkg/commands/tls/custom/privatekey/root.go @@ -0,0 +1,31 @@ +package privatekey + +import ( + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" +) + +// RootCommand is the parent command for all subcommands in this package. +// It should be installed under the primary root command. +type RootCommand struct { + argparser.Base + // no flags +} + +// CommandName is the string to be used to invoke this command. +const CommandName = "private-key" + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent argparser.Registerer, globals *global.Data) *RootCommand { + var c RootCommand + c.Globals = globals + c.CmdClause = parent.Command(CommandName, "Upload and manage private keys used to sign certificates") + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { + panic("unreachable") +} diff --git a/pkg/commands/tls/custom/privatekey/testdata/testkey.pem b/pkg/commands/tls/custom/privatekey/testdata/testkey.pem new file mode 100644 index 000000000..f3a59afab --- /dev/null +++ b/pkg/commands/tls/custom/privatekey/testdata/testkey.pem @@ -0,0 +1 @@ +this is a test key \ No newline at end of file diff --git a/pkg/commands/tls/custom/root.go b/pkg/commands/tls/custom/root.go new file mode 100644 index 000000000..8c714d764 --- /dev/null +++ b/pkg/commands/tls/custom/root.go @@ -0,0 +1,31 @@ +package custom + +import ( + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" +) + +// RootCommand is the parent command for all subcommands in this package. +// It should be installed under the primary root command. +type RootCommand struct { + argparser.Base + // no flags +} + +// CommandName is the string to be used to invoke this command. +const CommandName = "tls-custom" + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent argparser.Registerer, g *global.Data) *RootCommand { + var c RootCommand + c.Globals = g + c.CmdClause = parent.Command(CommandName, "Manage custom keys and certs used to enable TLS") + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { + panic("unreachable") +} diff --git a/pkg/commands/tls/platform/create.go b/pkg/commands/tls/platform/create.go new file mode 100644 index 000000000..47492d2ab --- /dev/null +++ b/pkg/commands/tls/platform/create.go @@ -0,0 +1,75 @@ +package platform + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// NewCreateCommand returns a usable command registered under the parent. +func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { + var c CreateCommand + c.CmdClause = parent.Command("upload", "Upload a new certificate") + c.Globals = g + + // Required. + c.CmdClause.Flag("cert-blob", "The PEM-formatted certificate blob").Required().StringVar(&c.certBlob) + c.CmdClause.Flag("intermediates-blob", "The PEM-formatted chain of intermediate blobs").Required().StringVar(&c.intermediatesBlob) + + // Optional. + c.CmdClause.Flag("allow-untrusted", "Allow certificates that chain to untrusted roots").Action(c.allowUntrusted.Set).BoolVar(&c.allowUntrusted.Value) + c.CmdClause.Flag("config", "Alphanumeric string identifying a TLS configuration (set flag once per Configuration ID)").StringsVar(&c.config) + + return &c +} + +// CreateCommand calls the Fastly API to update an appropriate resource. +type CreateCommand struct { + argparser.Base + + allowUntrusted argparser.OptionalBool + certBlob string + config []string + intermediatesBlob string +} + +// Exec invokes the application logic for the command. +func (c *CreateCommand) Exec(_ io.Reader, out io.Writer) error { + input := c.constructInput() + + r, err := c.Globals.APIClient.CreateBulkCertificate(input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Allow Untrusted": c.allowUntrusted.Value, + "Configs": c.config, + }) + return err + } + + text.Success(out, "Uploaded TLS Bulk Certificate '%s'", r.ID) + return nil +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *CreateCommand) constructInput() *fastly.CreateBulkCertificateInput { + var input fastly.CreateBulkCertificateInput + + input.CertBlob = c.certBlob + input.IntermediatesBlob = c.intermediatesBlob + + if c.allowUntrusted.WasSet { + input.AllowUntrusted = c.allowUntrusted.Value + } + + var configs []*fastly.TLSConfiguration + for _, v := range c.config { + configs = append(configs, &fastly.TLSConfiguration{ID: v}) + } + input.Configurations = configs + + return &input +} diff --git a/pkg/commands/tls/platform/delete.go b/pkg/commands/tls/platform/delete.go new file mode 100644 index 000000000..1bf5502c5 --- /dev/null +++ b/pkg/commands/tls/platform/delete.go @@ -0,0 +1,55 @@ +package platform + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// NewDeleteCommand returns a usable command registered under the parent. +func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { + var c DeleteCommand + c.CmdClause = parent.Command("delete", "Destroy a certificate. This disables TLS for all domains listed as SAN entries").Alias("remove") + c.Globals = g + + // Required. + c.CmdClause.Flag("id", "Alphanumeric string identifying a TLS bulk certificate").Required().StringVar(&c.id) + + return &c +} + +// DeleteCommand calls the Fastly API to delete an appropriate resource. +type DeleteCommand struct { + argparser.Base + + id string +} + +// Exec invokes the application logic for the command. +func (c *DeleteCommand) Exec(_ io.Reader, out io.Writer) error { + input := c.constructInput() + + err := c.Globals.APIClient.DeleteBulkCertificate(input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "TLS Bulk Certificate ID": c.id, + }) + return err + } + + text.Success(out, "Deleted TLS Bulk Certificate '%s'", c.id) + return nil +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *DeleteCommand) constructInput() *fastly.DeleteBulkCertificateInput { + var input fastly.DeleteBulkCertificateInput + + input.ID = c.id + + return &input +} diff --git a/pkg/commands/tls/platform/describe.go b/pkg/commands/tls/platform/describe.go new file mode 100644 index 000000000..bf359e108 --- /dev/null +++ b/pkg/commands/tls/platform/describe.go @@ -0,0 +1,89 @@ +package platform + +import ( + "fmt" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" +) + +// NewDescribeCommand returns a usable command registered under the parent. +func NewDescribeCommand(parent argparser.Registerer, g *global.Data) *DescribeCommand { + var c DescribeCommand + c.CmdClause = parent.Command("describe", "Retrieve a single certificate").Alias("get") + c.Globals = g + + // Required. + c.CmdClause.Flag("id", "Alphanumeric string identifying a TLS bulk certificate").Required().StringVar(&c.id) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + + return &c +} + +// DescribeCommand calls the Fastly API to describe an appropriate resource. +type DescribeCommand struct { + argparser.Base + argparser.JSONOutput + + id string +} + +// Exec invokes the application logic for the command. +func (c *DescribeCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + input := c.constructInput() + + o, err := c.Globals.APIClient.GetBulkCertificate(input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "TLS Bulk Certificate ID": c.id, + }) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + return c.print(out, o) +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *DescribeCommand) constructInput() *fastly.GetBulkCertificateInput { + var input fastly.GetBulkCertificateInput + + input.ID = c.id + + return &input +} + +// print displays the information returned from the API. +func (c *DescribeCommand) print(out io.Writer, r *fastly.BulkCertificate) error { + fmt.Fprintf(out, "\nID: %s\n", r.ID) + + if r.NotAfter != nil { + fmt.Fprintf(out, "Not after: %s\n", r.NotAfter) + } + if r.NotBefore != nil { + fmt.Fprintf(out, "Not before: %s\n", r.NotBefore) + } + if r.CreatedAt != nil { + fmt.Fprintf(out, "Created at: %s\n", r.CreatedAt) + } + if r.UpdatedAt != nil { + fmt.Fprintf(out, "Updated at: %s\n", r.UpdatedAt) + } + + fmt.Fprintf(out, "Replace: %t\n", r.Replace) + + return nil +} diff --git a/pkg/commands/tls/platform/doc.go b/pkg/commands/tls/platform/doc.go new file mode 100644 index 000000000..21f4d6bcf --- /dev/null +++ b/pkg/commands/tls/platform/doc.go @@ -0,0 +1,3 @@ +// Package platform contains commands to inspect and manipulate Fastly batch TLS +// certificates. +package platform diff --git a/pkg/commands/tls/platform/list.go b/pkg/commands/tls/platform/list.go new file mode 100644 index 000000000..f01c80128 --- /dev/null +++ b/pkg/commands/tls/platform/list.go @@ -0,0 +1,130 @@ +package platform + +import ( + "fmt" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// NewListCommand returns a usable command registered under the parent. +func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { + var c ListCommand + c.CmdClause = parent.Command("list", "List all certificates") + c.Globals = g + + // Optional. + c.CmdClause.Flag("filter-domain", "Optionally filter by the bulk attribute").StringVar(&c.filterTLSDomainID) + c.RegisterFlagBool(c.JSONFlag()) // --json + c.CmdClause.Flag("page", "Page number of data set to fetch").IntVar(&c.pageNumber) + c.CmdClause.Flag("per-page", "Number of records per page").IntVar(&c.pageSize) + c.CmdClause.Flag("sort", "The order in which to list the results by creation date").StringVar(&c.sort) + + return &c +} + +// ListCommand calls the Fastly API to list appropriate resources. +type ListCommand struct { + argparser.Base + argparser.JSONOutput + + filterTLSDomainID string + pageNumber int + pageSize int + sort string +} + +// Exec invokes the application logic for the command. +func (c *ListCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + input := c.constructInput() + + o, err := c.Globals.APIClient.ListBulkCertificates(input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Filter TLS Domain ID": c.filterTLSDomainID, + "Page Number": c.pageNumber, + "Page Size": c.pageSize, + "Sort": c.sort, + }) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + if c.Globals.Verbose() { + printVerbose(out, o) + } else { + err = c.printSummary(out, o) + if err != nil { + return err + } + } + return nil +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *ListCommand) constructInput() *fastly.ListBulkCertificatesInput { + var input fastly.ListBulkCertificatesInput + + if c.filterTLSDomainID != "" { + input.FilterTLSDomainsIDMatch = c.filterTLSDomainID + } + if c.pageNumber > 0 { + input.PageNumber = c.pageNumber + } + if c.pageSize > 0 { + input.PageSize = c.pageSize + } + if c.sort != "" { + input.Sort = c.sort + } + + return &input +} + +// printVerbose displays the information returned from the API in a verbose +// format. +func printVerbose(out io.Writer, rs []*fastly.BulkCertificate) { + for _, r := range rs { + fmt.Fprintf(out, "ID: %s\n", r.ID) + + if r.NotAfter != nil { + fmt.Fprintf(out, "Not after: %s\n", r.NotAfter) + } + if r.NotBefore != nil { + fmt.Fprintf(out, "Not before: %s\n", r.NotBefore) + } + if r.CreatedAt != nil { + fmt.Fprintf(out, "Created at: %s\n", r.CreatedAt) + } + if r.UpdatedAt != nil { + fmt.Fprintf(out, "Updated at: %s\n", r.UpdatedAt) + } + + fmt.Fprintf(out, "Replace: %t\n", r.Replace) + fmt.Fprintf(out, "\n") + } +} + +// printSummary displays the information returned from the API in a summarised +// format. +func (c *ListCommand) printSummary(out io.Writer, rs []*fastly.BulkCertificate) error { + t := text.NewTable(out) + t.AddHeader("ID", "REPLACE", "NOT BEFORE", "NOT AFTER", "CREATED") + for _, r := range rs { + t.AddLine(r.ID, r.Replace, r.NotBefore, r.NotAfter, r.CreatedAt) + } + t.Print() + return nil +} diff --git a/pkg/commands/tls/platform/platform_test.go b/pkg/commands/tls/platform/platform_test.go new file mode 100644 index 000000000..390ca9858 --- /dev/null +++ b/pkg/commands/tls/platform/platform_test.go @@ -0,0 +1,204 @@ +package platform_test + +import ( + "fmt" + "testing" + + "github.com/fastly/go-fastly/v10/fastly" + + root "github.com/fastly/cli/pkg/commands/tls/platform" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" +) + +const ( + validateAPIError = "validate API error" + validateAPISuccess = "validate API success" + validateMissingIDFlag = "validate missing --id flag" + mockResponseID = "123" +) + +func TestTLSPlatformUpload(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --cert-blob flag", + Args: "--intermediates-blob example", + WantError: "required flag --cert-blob not provided", + }, + { + Name: "validate missing --intermediates-blob flag", + Args: "--cert-blob example", + WantError: "required flag --intermediates-blob not provided", + }, + { + Name: validateAPIError, + API: mock.API{ + CreateBulkCertificateFn: func(_ *fastly.CreateBulkCertificateInput) (*fastly.BulkCertificate, error) { + return nil, testutil.Err + }, + }, + Args: "--cert-blob example --intermediates-blob example", + WantError: testutil.Err.Error(), + }, + { + Name: validateAPISuccess, + API: mock.API{ + CreateBulkCertificateFn: func(_ *fastly.CreateBulkCertificateInput) (*fastly.BulkCertificate, error) { + return &fastly.BulkCertificate{ + ID: mockResponseID, + }, nil + }, + }, + Args: "--cert-blob example --intermediates-blob example", + WantOutput: fmt.Sprintf("Uploaded TLS Bulk Certificate '%s'", mockResponseID), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, "upload"}, scenarios) +} + +func TestTLSPlatformDelete(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: validateMissingIDFlag, + WantError: "error parsing arguments: required flag --id not provided", + }, + { + Name: validateAPIError, + API: mock.API{ + DeleteBulkCertificateFn: func(_ *fastly.DeleteBulkCertificateInput) error { + return testutil.Err + }, + }, + Args: "--id example", + WantError: testutil.Err.Error(), + }, + { + Name: validateAPISuccess, + API: mock.API{ + DeleteBulkCertificateFn: func(_ *fastly.DeleteBulkCertificateInput) error { + return nil + }, + }, + Args: "--id example", + WantOutput: "Deleted TLS Bulk Certificate 'example'", + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, "delete"}, scenarios) +} + +func TestTLSPlatformDescribe(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: validateMissingIDFlag, + WantError: "error parsing arguments: required flag --id not provided", + }, + { + Name: validateAPIError, + API: mock.API{ + GetBulkCertificateFn: func(_ *fastly.GetBulkCertificateInput) (*fastly.BulkCertificate, error) { + return nil, testutil.Err + }, + }, + Args: "--id example", + WantError: testutil.Err.Error(), + }, + { + Name: validateAPISuccess, + API: mock.API{ + GetBulkCertificateFn: func(_ *fastly.GetBulkCertificateInput) (*fastly.BulkCertificate, error) { + t := testutil.Date + return &fastly.BulkCertificate{ + ID: "123", + CreatedAt: &t, + UpdatedAt: &t, + Replace: true, + }, nil + }, + }, + Args: "--id example", + WantOutput: "\nID: 123\nCreated at: 2021-06-15 23:00:00 +0000 UTC\nUpdated at: 2021-06-15 23:00:00 +0000 UTC\nReplace: true\n", + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, "describe"}, scenarios) +} + +func TestTLSPlatformList(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: validateAPIError, + API: mock.API{ + ListBulkCertificatesFn: func(_ *fastly.ListBulkCertificatesInput) ([]*fastly.BulkCertificate, error) { + return nil, testutil.Err + }, + }, + WantError: testutil.Err.Error(), + }, + { + Name: validateAPISuccess, + API: mock.API{ + ListBulkCertificatesFn: func(_ *fastly.ListBulkCertificatesInput) ([]*fastly.BulkCertificate, error) { + t := testutil.Date + return []*fastly.BulkCertificate{ + { + ID: mockResponseID, + CreatedAt: &t, + UpdatedAt: &t, + Replace: true, + }, + }, nil + }, + }, + Args: "--verbose", + WantOutput: "\nID: " + mockResponseID + "\nCreated at: 2021-06-15 23:00:00 +0000 UTC\nUpdated at: 2021-06-15 23:00:00 +0000 UTC\nReplace: true\n", + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, "list"}, scenarios) +} + +func TestTLSPlatformUpdate(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: validateMissingIDFlag, + Args: "--cert-blob example --intermediates-blob example", + WantError: "required flag --id not provided", + }, + { + Name: "validate missing --cert-blob flag", + Args: "--id example --intermediates-blob example", + WantError: "required flag --cert-blob not provided", + }, + { + Name: "validate missing --intermediates-blob flag", + Args: "--id example --cert-blob example", + WantError: "required flag --intermediates-blob not provided", + }, + { + Name: validateAPIError, + API: mock.API{ + UpdateBulkCertificateFn: func(_ *fastly.UpdateBulkCertificateInput) (*fastly.BulkCertificate, error) { + return nil, testutil.Err + }, + }, + Args: "--id example --cert-blob example --intermediates-blob example", + WantError: testutil.Err.Error(), + }, + { + Name: validateAPISuccess, + API: mock.API{ + UpdateBulkCertificateFn: func(_ *fastly.UpdateBulkCertificateInput) (*fastly.BulkCertificate, error) { + return &fastly.BulkCertificate{ + ID: mockResponseID, + }, nil + }, + }, + Args: "--id example --cert-blob example --intermediates-blob example", + WantOutput: "Updated TLS Bulk Certificate '123'", + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, "update"}, scenarios) +} diff --git a/pkg/commands/tls/platform/root.go b/pkg/commands/tls/platform/root.go new file mode 100644 index 000000000..19cb783ac --- /dev/null +++ b/pkg/commands/tls/platform/root.go @@ -0,0 +1,31 @@ +package platform + +import ( + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" +) + +// RootCommand is the parent command for all subcommands in this package. +// It should be installed under the primary root command. +type RootCommand struct { + argparser.Base + // no flags +} + +// CommandName is the string to be used to invoke this command. +const CommandName = "tls-platform" + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent argparser.Registerer, g *global.Data) *RootCommand { + var c RootCommand + c.Globals = g + c.CmdClause = parent.Command(CommandName, "Manage large numbers of TLS certificates") + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { + panic("unreachable") +} diff --git a/pkg/commands/tls/platform/update.go b/pkg/commands/tls/platform/update.go new file mode 100644 index 000000000..0ab8a134c --- /dev/null +++ b/pkg/commands/tls/platform/update.go @@ -0,0 +1,85 @@ +package platform + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// NewUpdateCommand returns a usable command registered under the parent. +func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { + var c UpdateCommand + c.CmdClause = parent.Command( + "update", "Replace a certificate with a newly reissued certificate", + ) + c.Globals = g + + // Required. + + c.CmdClause.Flag( + "id", "Alphanumeric string identifying a TLS bulk certificate", + ).Required().StringVar(&c.id) + + c.CmdClause.Flag( + "cert-blob", "The PEM-formatted certificate blob", + ).Required().StringVar(&c.certBlob) + + c.CmdClause.Flag( + "intermediates-blob", "The PEM-formatted chain of intermediate blobs", + ).Required().StringVar(&c.intermediatesBlob) + + // Optional. + + c.CmdClause.Flag( + "allow-untrusted", "Allow certificates that chain to untrusted roots", + ).Action(c.allowUntrusted.Set).BoolVar(&c.allowUntrusted.Value) + + return &c +} + +// UpdateCommand calls the Fastly API to update an appropriate resource. +type UpdateCommand struct { + argparser.Base + + allowUntrusted argparser.OptionalBool + certBlob string + id string + intermediatesBlob string +} + +// Exec invokes the application logic for the command. +func (c *UpdateCommand) Exec(_ io.Reader, out io.Writer) error { + input := c.constructInput() + + r, err := c.Globals.APIClient.UpdateBulkCertificate(input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "TLS Bulk Certificate ID": c.id, + "Allow Untrusted": c.allowUntrusted.Value, + }) + return err + } + + text.Success(out, "Updated TLS Bulk Certificate '%s'", r.ID) + return nil +} + +// constructInput transforms values parsed from CLI flags into an object to be +// used by the API client library. +func (c *UpdateCommand) constructInput() *fastly.UpdateBulkCertificateInput { + var input fastly.UpdateBulkCertificateInput + + input.ID = c.id + input.CertBlob = c.certBlob + input.IntermediatesBlob = c.intermediatesBlob + + if c.allowUntrusted.WasSet { + input.AllowUntrusted = c.allowUntrusted.Value + } + + return &input +} diff --git a/pkg/commands/tls/subscription/create.go b/pkg/commands/tls/subscription/create.go new file mode 100644 index 000000000..4b7dc246b --- /dev/null +++ b/pkg/commands/tls/subscription/create.go @@ -0,0 +1,84 @@ +package subscription + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +const emptyString = "" + +var certAuth = []string{"certainly", "lets-encrypt", "globalsign"} + +// NewCreateCommand returns a usable command registered under the parent. +func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { + var c CreateCommand + c.CmdClause = parent.Command("create", "Create a new TLS subscription").Alias("add") + c.Globals = g + + // Required. + c.CmdClause.Flag("domain", "Domain(s) to add to the TLS certificates generated for the subscription (set flag once per domain)").Required().StringsVar(&c.domains) + + // Optional. + c.CmdClause.Flag("cert-auth", "The entity that issues and certifies the TLS certificates for your subscription. Valid values are certainly, lets-encrypt, and globalsign").HintOptions(certAuth...).EnumVar(&c.certAuth, certAuth...) + c.CmdClause.Flag("common-name", "The domain name associated with the subscription. Default to the first domain specified by --domain").StringVar(&c.commonName) + c.CmdClause.Flag("config", "Alphanumeric string identifying a TLS configuration").StringVar(&c.config) + + return &c +} + +// CreateCommand calls the Fastly API to create an appropriate resource. +type CreateCommand struct { + argparser.Base + + certAuth string + commonName string + config string + domains []string +} + +// Exec invokes the application logic for the command. +func (c *CreateCommand) Exec(_ io.Reader, out io.Writer) error { + input := c.constructInput() + + r, err := c.Globals.APIClient.CreateTLSSubscription(input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "TLS Domains": c.domains, + "TLS Common Name": c.commonName, + "TLS Configuration ID": c.config, + "TLS Certificate Authority": c.certAuth, + }) + return err + } + + text.Success(out, "Created TLS Subscription '%s' (Authority: %s, Common Name: %s)", r.ID, r.CertificateAuthority, r.CommonName.ID) + return nil +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *CreateCommand) constructInput() *fastly.CreateTLSSubscriptionInput { + var input fastly.CreateTLSSubscriptionInput + + domains := make([]*fastly.TLSDomain, len(c.domains)) + for i, v := range c.domains { + domains[i] = &fastly.TLSDomain{ID: v} + } + input.Domains = domains + + if c.commonName != emptyString { + input.CommonName = &fastly.TLSDomain{ID: c.commonName} + } + if c.certAuth != emptyString { + input.CertificateAuthority = c.certAuth + } + if c.config != emptyString { + input.Configuration = &fastly.TLSConfiguration{ID: c.config} + } + + return &input +} diff --git a/pkg/commands/tls/subscription/delete.go b/pkg/commands/tls/subscription/delete.go new file mode 100644 index 000000000..787d9fb98 --- /dev/null +++ b/pkg/commands/tls/subscription/delete.go @@ -0,0 +1,64 @@ +package subscription + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// NewDeleteCommand returns a usable command registered under the parent. +func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { + var c DeleteCommand + c.CmdClause = parent.Command("delete", "Destroy a TLS subscription. A subscription cannot be destroyed if there are domains in the TLS enabled state").Alias("remove") + c.Globals = g + + // Required. + c.CmdClause.Flag("id", "Alphanumeric string identifying a TLS subscription").Required().StringVar(&c.id) + + // Optional. + c.CmdClause.Flag("force", "A flag that allows you to edit and delete a subscription with active domains").Action(c.force.Set).BoolVar(&c.force.Value) + + return &c +} + +// DeleteCommand calls the Fastly API to delete an appropriate resource. +type DeleteCommand struct { + argparser.Base + + force argparser.OptionalBool + id string +} + +// Exec invokes the application logic for the command. +func (c *DeleteCommand) Exec(_ io.Reader, out io.Writer) error { + input := c.constructInput() + + err := c.Globals.APIClient.DeleteTLSSubscription(input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "TLS Subscription ID": c.id, + "Force": c.force.Value, + }) + return err + } + + text.Success(out, "Deleted TLS Subscription '%s' (force: %t)", c.id, c.force.Value) + return nil +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *DeleteCommand) constructInput() *fastly.DeleteTLSSubscriptionInput { + var input fastly.DeleteTLSSubscriptionInput + + input.ID = c.id + + if c.force.WasSet { + input.Force = c.force.Value + } + + return &input +} diff --git a/pkg/commands/tls/subscription/describe.go b/pkg/commands/tls/subscription/describe.go new file mode 100644 index 000000000..c5ed37d39 --- /dev/null +++ b/pkg/commands/tls/subscription/describe.go @@ -0,0 +1,92 @@ +package subscription + +import ( + "fmt" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" +) + +var include = []string{"tls_authorizations", "tls_authorizations.globalsign_email_challenge"} + +// NewDescribeCommand returns a usable command registered under the parent. +func NewDescribeCommand(parent argparser.Registerer, g *global.Data) *DescribeCommand { + var c DescribeCommand + c.CmdClause = parent.Command("describe", "Show a TLS subscription").Alias("get") + c.Globals = g + + // Required. + c.CmdClause.Flag("id", "Alphanumeric string identifying a TLS subscription").Required().StringVar(&c.id) + + // Optional. + c.CmdClause.Flag("include", "Include related objects (comma-separated values)").HintOptions(include...).EnumVar(&c.include, include...) + c.RegisterFlagBool(c.JSONFlag()) // --json + + return &c +} + +// DescribeCommand calls the Fastly API to describe an appropriate resource. +type DescribeCommand struct { + argparser.Base + argparser.JSONOutput + + id string + include string +} + +// Exec invokes the application logic for the command. +func (c *DescribeCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + input := c.constructInput() + + o, err := c.Globals.APIClient.GetTLSSubscription(input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "TLS Subscription ID": c.id, + "Include": c.include, + }) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + return c.print(out, o) +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *DescribeCommand) constructInput() *fastly.GetTLSSubscriptionInput { + var input fastly.GetTLSSubscriptionInput + + input.ID = c.id + + if c.include != "" { + input.Include = &c.include + } + + return &input +} + +// print displays the information returned from the API. +func (c *DescribeCommand) print(out io.Writer, r *fastly.TLSSubscription) error { + fmt.Fprintf(out, "\nID: %s\n", r.ID) + fmt.Fprintf(out, "Certificate Authority: %s\n", r.CertificateAuthority) + fmt.Fprintf(out, "State: %s\n", r.State) + + if r.CreatedAt != nil { + fmt.Fprintf(out, "Created at: %s\n", r.CreatedAt) + } + if r.UpdatedAt != nil { + fmt.Fprintf(out, "Updated at: %s\n", r.UpdatedAt) + } + + return nil +} diff --git a/pkg/commands/tls/subscription/doc.go b/pkg/commands/tls/subscription/doc.go new file mode 100644 index 000000000..dcbbe373d --- /dev/null +++ b/pkg/commands/tls/subscription/doc.go @@ -0,0 +1,3 @@ +// Package subscription contains commands to inspect and manipulate Fastly +// procured TLS certificates. +package subscription diff --git a/pkg/commands/tls/subscription/list.go b/pkg/commands/tls/subscription/list.go new file mode 100644 index 000000000..ca5768015 --- /dev/null +++ b/pkg/commands/tls/subscription/list.go @@ -0,0 +1,145 @@ +package subscription + +import ( + "fmt" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +var states = []string{"pending", "processing", "issued", "renewing"} + +// NewListCommand returns a usable command registered under the parent. +func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { + var c ListCommand + c.CmdClause = parent.Command("list", "List all TLS subscriptions") + c.Globals = g + + // Optional. + c.CmdClause.Flag("filter-active", "Limit the returned subscriptions to those that have currently active orders").BoolVar(&c.filterHasActiveOrder) + c.CmdClause.Flag("filter-domain", "Limit the returned subscriptions to those that include the specific domain").StringVar(&c.filterTLSDomainID) + c.CmdClause.Flag("filter-state", "Limit the returned subscriptions by state").HintOptions(states...).EnumVar(&c.filterState, states...) + c.CmdClause.Flag("include", "Include related objects (comma-separated values)").HintOptions(include...).EnumVar(&c.include, include...) // include is defined in ./describe.go + c.RegisterFlagBool(c.JSONFlag()) // --json + c.CmdClause.Flag("page", "Page number of data set to fetch").IntVar(&c.pageNumber) + c.CmdClause.Flag("per-page", "Number of records per page").IntVar(&c.pageSize) + c.CmdClause.Flag("sort", "The order in which to list the results by creation date").StringVar(&c.sort) + + return &c +} + +// ListCommand calls the Fastly API to list appropriate resources. +type ListCommand struct { + argparser.Base + argparser.JSONOutput + + filterHasActiveOrder bool + filterState string + filterTLSDomainID string + include string + pageNumber int + pageSize int + sort string +} + +// Exec invokes the application logic for the command. +func (c *ListCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + input := c.constructInput() + + o, err := c.Globals.APIClient.ListTLSSubscriptions(input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Filter Active": c.filterHasActiveOrder, + "Filter State": c.filterState, + "Filter TLS Domain ID": c.filterTLSDomainID, + "Include": c.include, + "Page Number": c.pageNumber, + "Page Size": c.pageSize, + "Sort": c.sort, + }) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + if c.Globals.Verbose() { + c.printVerbose(out, o) + } else { + err = c.printSummary(out, o) + if err != nil { + return err + } + } + return nil +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *ListCommand) constructInput() *fastly.ListTLSSubscriptionsInput { + var input fastly.ListTLSSubscriptionsInput + + if c.filterHasActiveOrder { + input.FilterActiveOrders = c.filterHasActiveOrder + } + if c.filterState != "" { + input.FilterState = c.filterState + } + if c.filterTLSDomainID != "" { + input.FilterTLSDomainsID = c.filterTLSDomainID + } + if c.include != "" { + input.Include = c.include + } + if c.pageNumber > 0 { + input.PageNumber = c.pageNumber + } + if c.pageSize > 0 { + input.PageSize = c.pageSize + } + if c.sort != "" { + input.Sort = c.sort + } + + return &input +} + +// printVerbose displays the information returned from the API in a verbose +// format. +func (c *ListCommand) printVerbose(out io.Writer, rs []*fastly.TLSSubscription) { + for _, r := range rs { + fmt.Fprintf(out, "ID: %s\n", r.ID) + fmt.Fprintf(out, "Certificate Authority: %s\n", r.CertificateAuthority) + fmt.Fprintf(out, "State: %s\n", r.State) + + if r.CreatedAt != nil { + fmt.Fprintf(out, "Created at: %s\n", r.CreatedAt) + } + if r.UpdatedAt != nil { + fmt.Fprintf(out, "Updated at: %s\n", r.UpdatedAt) + } + + fmt.Fprintf(out, "\n") + } +} + +// printSummary displays the information returned from the API in a summarised +// format. +func (c *ListCommand) printSummary(out io.Writer, rs []*fastly.TLSSubscription) error { + t := text.NewTable(out) + t.AddHeader("ID", "CERT AUTHORITY", "STATE", "CREATED") + for _, r := range rs { + t.AddLine(r.ID, r.CertificateAuthority, r.State, r.CreatedAt) + } + t.Print() + return nil +} diff --git a/pkg/commands/tls/subscription/root.go b/pkg/commands/tls/subscription/root.go new file mode 100644 index 000000000..687efac01 --- /dev/null +++ b/pkg/commands/tls/subscription/root.go @@ -0,0 +1,31 @@ +package subscription + +import ( + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" +) + +// RootCommand is the parent command for all subcommands in this package. +// It should be installed under the primary root command. +type RootCommand struct { + argparser.Base + // no flags +} + +// CommandName is the string to be used to invoke this command. +const CommandName = "tls-subscription" + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent argparser.Registerer, g *global.Data) *RootCommand { + var c RootCommand + c.Globals = g + c.CmdClause = parent.Command(CommandName, "Generate TLS certificates procured and renewed by Fastly") + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { + panic("unreachable") +} diff --git a/pkg/commands/tls/subscription/subscription_test.go b/pkg/commands/tls/subscription/subscription_test.go new file mode 100644 index 000000000..db687b084 --- /dev/null +++ b/pkg/commands/tls/subscription/subscription_test.go @@ -0,0 +1,254 @@ +package subscription_test + +import ( + "fmt" + "testing" + + "github.com/fastly/go-fastly/v10/fastly" + + root "github.com/fastly/cli/pkg/commands/tls/subscription" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" +) + +const ( + certificateAuthority = "lets-encrypt" + mockResponseID = "123" + validateAPIError = "validate API error" + validateAPISuccess = "validate API success" + validateMissingIDFlag = "validate missing --id flag" +) + +func TestTLSSubscriptionCreate(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --domain flag", + WantError: "required flag --domain not provided", + }, + { + Name: validateAPIError, + API: mock.API{ + CreateTLSSubscriptionFn: func(_ *fastly.CreateTLSSubscriptionInput) (*fastly.TLSSubscription, error) { + return nil, testutil.Err + }, + }, + Args: "--domain example.com", + WantError: testutil.Err.Error(), + }, + { + Name: validateAPISuccess, + API: mock.API{ + CreateTLSSubscriptionFn: func(_ *fastly.CreateTLSSubscriptionInput) (*fastly.TLSSubscription, error) { + return &fastly.TLSSubscription{ + ID: mockResponseID, + CertificateAuthority: certificateAuthority, + CommonName: &fastly.TLSDomain{ + ID: "example.com", + }, + }, nil + }, + }, + Args: "--domain example.com", + WantOutput: fmt.Sprintf("Created TLS Subscription '%s' (Authority: %s, Common Name: example.com)", mockResponseID, certificateAuthority), + }, + { + Name: "validate cert-auth == certainly", + API: mock.API{ + CreateTLSSubscriptionFn: func(i *fastly.CreateTLSSubscriptionInput) (*fastly.TLSSubscription, error) { + return &fastly.TLSSubscription{ + ID: mockResponseID, + CertificateAuthority: i.CertificateAuthority, + CommonName: i.Domains[0], + }, nil + }, + }, + Args: "--domain example.com --cert-auth certainly", + WantOutput: fmt.Sprintf("Created TLS Subscription '%s' (Authority: certainly, Common Name: example.com)", mockResponseID), + }, + { + Name: "validate cert-auth == lets-encrypt", + API: mock.API{ + CreateTLSSubscriptionFn: func(i *fastly.CreateTLSSubscriptionInput) (*fastly.TLSSubscription, error) { + return &fastly.TLSSubscription{ + ID: mockResponseID, + CertificateAuthority: i.CertificateAuthority, + CommonName: i.Domains[0], + }, nil + }, + }, + Args: "--domain example.com --cert-auth lets-encrypt", + WantOutput: fmt.Sprintf("Created TLS Subscription '%s' (Authority: lets-encrypt, Common Name: example.com)", mockResponseID), + }, + { + Name: "validate cert-auth == globalsign", + API: mock.API{ + CreateTLSSubscriptionFn: func(i *fastly.CreateTLSSubscriptionInput) (*fastly.TLSSubscription, error) { + return &fastly.TLSSubscription{ + ID: mockResponseID, + CertificateAuthority: i.CertificateAuthority, + CommonName: i.Domains[0], + }, nil + }, + }, + Args: "--domain example.com --cert-auth globalsign", + WantOutput: fmt.Sprintf("Created TLS Subscription '%s' (Authority: globalsign, Common Name: example.com)", mockResponseID), + }, + { + Name: "validate cert-auth is invalid", + API: mock.API{ + CreateTLSSubscriptionFn: func(i *fastly.CreateTLSSubscriptionInput) (*fastly.TLSSubscription, error) { + return &fastly.TLSSubscription{ + ID: mockResponseID, + CertificateAuthority: i.CertificateAuthority, + CommonName: i.Domains[0], + }, nil + }, + }, + Args: "--domain example.com --cert-auth not-valid", + WantError: "enum value must be one of certainly,lets-encrypt,globalsign", + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, "create"}, scenarios) +} + +func TestTLSSubscriptionDelete(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: validateMissingIDFlag, + WantError: "error parsing arguments: required flag --id not provided", + }, + { + Name: validateAPIError, + API: mock.API{ + DeleteTLSSubscriptionFn: func(_ *fastly.DeleteTLSSubscriptionInput) error { + return testutil.Err + }, + }, + Args: "--id example", + WantError: testutil.Err.Error(), + }, + { + Name: validateAPISuccess, + API: mock.API{ + DeleteTLSSubscriptionFn: func(_ *fastly.DeleteTLSSubscriptionInput) error { + return nil + }, + }, + Args: "--id example", + WantOutput: "Deleted TLS Subscription 'example' (force: false)", + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, "delete"}, scenarios) +} + +func TestTLSSubscriptionDescribe(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: validateMissingIDFlag, + WantError: "error parsing arguments: required flag --id not provided", + }, + { + Name: validateAPIError, + API: mock.API{ + GetTLSSubscriptionFn: func(_ *fastly.GetTLSSubscriptionInput) (*fastly.TLSSubscription, error) { + return nil, testutil.Err + }, + }, + Args: "--id example", + WantError: testutil.Err.Error(), + }, + { + Name: validateAPISuccess, + API: mock.API{ + GetTLSSubscriptionFn: func(_ *fastly.GetTLSSubscriptionInput) (*fastly.TLSSubscription, error) { + t := testutil.Date + return &fastly.TLSSubscription{ + ID: mockResponseID, + CertificateAuthority: certificateAuthority, + State: "pending", + CreatedAt: &t, + UpdatedAt: &t, + }, nil + }, + }, + Args: "--id example", + WantOutput: "\nID: " + mockResponseID + "\nCertificate Authority: " + certificateAuthority + "\nState: pending\nCreated at: 2021-06-15 23:00:00 +0000 UTC\nUpdated at: 2021-06-15 23:00:00 +0000 UTC\n", + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, "describe"}, scenarios) +} + +func TestTLSSubscriptionList(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: validateAPIError, + API: mock.API{ + ListTLSSubscriptionsFn: func(_ *fastly.ListTLSSubscriptionsInput) ([]*fastly.TLSSubscription, error) { + return nil, testutil.Err + }, + }, + WantError: testutil.Err.Error(), + }, + { + Name: validateAPISuccess, + API: mock.API{ + ListTLSSubscriptionsFn: func(_ *fastly.ListTLSSubscriptionsInput) ([]*fastly.TLSSubscription, error) { + t := testutil.Date + return []*fastly.TLSSubscription{ + { + ID: mockResponseID, + CertificateAuthority: certificateAuthority, + State: "pending", + CreatedAt: &t, + UpdatedAt: &t, + }, + }, nil + }, + }, + Args: "--verbose", + WantOutput: "\nID: " + mockResponseID + "\nCertificate Authority: " + certificateAuthority + "\nState: pending\nCreated at: 2021-06-15 23:00:00 +0000 UTC\nUpdated at: 2021-06-15 23:00:00 +0000 UTC\n", + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, "list"}, scenarios) +} + +func TestTLSSubscriptionUpdate(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: validateMissingIDFlag, + WantError: "required flag --id not provided", + }, + { + Name: validateAPIError, + API: mock.API{ + UpdateTLSSubscriptionFn: func(_ *fastly.UpdateTLSSubscriptionInput) (*fastly.TLSSubscription, error) { + return nil, testutil.Err + }, + }, + Args: "--id example", + WantError: testutil.Err.Error(), + }, + { + Name: validateAPISuccess, + API: mock.API{ + UpdateTLSSubscriptionFn: func(_ *fastly.UpdateTLSSubscriptionInput) (*fastly.TLSSubscription, error) { + return &fastly.TLSSubscription{ + ID: mockResponseID, + CertificateAuthority: certificateAuthority, + CommonName: &fastly.TLSDomain{ + ID: "example.com", + }, + }, nil + }, + }, + Args: "--id example", + WantOutput: fmt.Sprintf("Updated TLS Subscription '%s' (Authority: %s, Common Name: example.com)", mockResponseID, certificateAuthority), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, "update"}, scenarios) +} diff --git a/pkg/commands/tls/subscription/update.go b/pkg/commands/tls/subscription/update.go new file mode 100644 index 000000000..c95d4e2f9 --- /dev/null +++ b/pkg/commands/tls/subscription/update.go @@ -0,0 +1,82 @@ +package subscription + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// NewUpdateCommand returns a usable command registered under the parent. +func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { + var c UpdateCommand + c.CmdClause = parent.Command("update", "Change the TLS domains or common name associated with this subscription, or update the TLS configuration for this set of domains") + c.Globals = g + + // Required. + c.CmdClause.Flag("id", "Alphanumeric string identifying a TLS subscription").Required().StringVar(&c.id) + + // Optional. + c.CmdClause.Flag("common-name", "The domain name associated with the subscription").StringVar(&c.commonName) + c.CmdClause.Flag("config", "Alphanumeric string identifying a TLS configuration").StringVar(&c.config) + c.CmdClause.Flag("domain", "Domain(s) to add to the TLS certificates generated for the subscription (set flag once per domain)").StringsVar(&c.domains) + c.CmdClause.Flag("force", "A flag that allows you to edit and delete a subscription with active domains").Action(c.force.Set).BoolVar(&c.force.Value) + + return &c +} + +// UpdateCommand calls the Fastly API to update an appropriate resource. +type UpdateCommand struct { + argparser.Base + + commonName string + config string + domains []string + force argparser.OptionalBool + id string +} + +// Exec invokes the application logic for the command. +func (c *UpdateCommand) Exec(_ io.Reader, out io.Writer) error { + input := c.constructInput() + + r, err := c.Globals.APIClient.UpdateTLSSubscription(input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "TLS Subscription ID": c.id, + "Force": c.force.Value, + }) + return err + } + + text.Success(out, "Updated TLS Subscription '%s' (Authority: %s, Common Name: %s)", r.ID, r.CertificateAuthority, r.CommonName.ID) + return nil +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *UpdateCommand) constructInput() *fastly.UpdateTLSSubscriptionInput { + var input fastly.UpdateTLSSubscriptionInput + + input.ID = c.id + + domains := make([]*fastly.TLSDomain, len(c.domains)) + for i, v := range c.domains { + domains[i] = &fastly.TLSDomain{ID: v} + } + input.Domains = domains + + if c.commonName != "" { + input.CommonName = &fastly.TLSDomain{ID: c.commonName} + } + if c.config != "" { + input.Configuration = &fastly.TLSConfiguration{ID: c.config} + } + if c.force.WasSet { + input.Force = c.force.Value + } + + return &input +} diff --git a/pkg/commands/update/check.go b/pkg/commands/update/check.go new file mode 100644 index 000000000..4ebd6eeb5 --- /dev/null +++ b/pkg/commands/update/check.go @@ -0,0 +1,73 @@ +package update + +import ( + "fmt" + "io" + "strings" + + "github.com/blang/semver" + + "github.com/fastly/cli/pkg/github" +) + +// Check if the CLI can be updated. +func Check(currentVersion string, av github.AssetVersioner) (current, latest semver.Version, shouldUpdate bool) { + // nosemgrep (invalid-usage-of-modified-variable) + current, err := semver.Parse(strings.TrimPrefix(currentVersion, "v")) + if err != nil { + return current, latest, false + } + + v, err := av.LatestVersion() + if err != nil { + return current, latest, false + } + + // nosemgrep (invalid-usage-of-modified-variable) + latest, err = semver.Parse(v) + if err != nil { + return current, latest, false + } + + return current, latest, latest.GT(current) +} + +type checkResult struct { + current semver.Version + latest semver.Version + shouldUpdate bool +} + +// CheckAsync is a helper function for running Check asynchronously. +// +// Launches a goroutine to perform a check for the latest CLI version using the +// provided context and return a function that will print an informative message +// to the writer if there is a newer version available. +// +// Callers should invoke CheckAsync via +// +// f := CheckAsync(...) +// defer f() +func CheckAsync( + currentVersion string, + av github.AssetVersioner, + quietMode bool, +) (printResults func(io.Writer)) { + results := make(chan checkResult, 1) + go func() { + current, latest, shouldUpdate := Check(currentVersion, av) + results <- checkResult{current, latest, shouldUpdate} + }() + + return func(w io.Writer) { + result := <-results + if result.shouldUpdate && !quietMode { + fmt.Fprintf(w, "\n") + fmt.Fprintf(w, "A new version of the Fastly CLI is available.\n") + fmt.Fprintf(w, "Current version: %s\n", result.current) + fmt.Fprintf(w, "Latest version: %s\n", result.latest) + fmt.Fprintf(w, "Run `fastly update` to get the latest version.\n") + fmt.Fprintf(w, "\n") + } + } +} diff --git a/pkg/commands/update/check_test.go b/pkg/commands/update/check_test.go new file mode 100644 index 000000000..b39a5b067 --- /dev/null +++ b/pkg/commands/update/check_test.go @@ -0,0 +1,114 @@ +package update_test + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "testing" + "time" + + "github.com/blang/semver" + "github.com/google/go-cmp/cmp" + + "github.com/fastly/cli/pkg/commands/update" + "github.com/fastly/cli/pkg/config" + "github.com/fastly/cli/pkg/github" + "github.com/fastly/cli/pkg/mock" +) + +func TestCheck(t *testing.T) { + for _, testcase := range []struct { + name string + current string + av github.AssetVersioner + wantCurrent semver.Version + wantLatest semver.Version + wantUpdate bool + }{ + { + name: "empty current version", + current: "", + av: mock.AssetVersioner{}, + }, + { + name: "invalid current version", + current: "unknown", + av: mock.AssetVersioner{}, + }, + { + name: "same version", + current: "v1.2.3", + av: mock.AssetVersioner{AssetVersion: "1.2.3"}, + wantCurrent: semver.MustParse("1.2.3"), + wantLatest: semver.MustParse("1.2.3"), + wantUpdate: false, + }, + { + name: "new version", + current: "v1.2.3", + av: mock.AssetVersioner{AssetVersion: "1.2.4"}, + wantCurrent: semver.MustParse("1.2.3"), + wantLatest: semver.MustParse("1.2.4"), + wantUpdate: true, + }, + } { + t.Run(testcase.name, func(t *testing.T) { + current, latest, shouldUpdate := update.Check(testcase.current, testcase.av) + if want, have := testcase.wantCurrent, current; !want.Equals(have) { + t.Fatalf("current version: want %s, have %s", want, have) + } + if want, have := testcase.wantLatest, latest; !want.Equals(have) { + t.Fatalf("latest version: want %s, have %s", want, have) + } + if want, have := testcase.wantUpdate, shouldUpdate; want != have { + t.Fatalf("should update: want %v, have %v", want, have) + } + }) + } +} + +func TestCheckAsync(t *testing.T) { + for _, testcase := range []struct { + name string + file config.File + currentVersion string + av github.AssetVersioner + wantOutput string + }{ + { + name: "no last_check same version", + currentVersion: "0.0.1", + av: mock.AssetVersioner{AssetVersion: "0.0.1"}, + }, + { + name: "no last_check new version", + currentVersion: "0.0.1", + av: mock.AssetVersioner{AssetVersion: "0.0.2"}, + wantOutput: "\nA new version of the Fastly CLI is available.\nCurrent version: 0.0.1\nLatest version: 0.0.2\nRun `fastly update` to get the latest version.\n\n", + }, + { + name: "recent last_check new version", + currentVersion: "0.0.1", + av: mock.AssetVersioner{AssetVersion: "0.0.2"}, + wantOutput: "\nA new version of the Fastly CLI is available.\nCurrent version: 0.0.1\nLatest version: 0.0.2\nRun `fastly update` to get the latest version.\n\n", + }, + } { + t.Run(testcase.name, func(t *testing.T) { + configFilePath := filepath.Join(os.TempDir(), fmt.Sprintf("fastly_TestCheckAsync_%d", time.Now().UnixNano())) + defer os.RemoveAll(configFilePath) + + var buf bytes.Buffer + f := update.CheckAsync( + testcase.currentVersion, + testcase.av, + false, + ) + f(&buf) + + if want, have := testcase.wantOutput, buf.String(); want != have { + t.Error(cmp.Diff(want, have)) + } + }) + } +} diff --git a/pkg/commands/update/doc.go b/pkg/commands/update/doc.go new file mode 100644 index 000000000..4e6ef4220 --- /dev/null +++ b/pkg/commands/update/doc.go @@ -0,0 +1,3 @@ +// Package update contains functions for checking the current CLI version +// against the latest version release. +package update diff --git a/pkg/commands/update/root.go b/pkg/commands/update/root.go new file mode 100644 index 000000000..bc98c1549 --- /dev/null +++ b/pkg/commands/update/root.go @@ -0,0 +1,155 @@ +package update + +import ( + "fmt" + "io" + "os" + "path/filepath" + + "github.com/blang/semver" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/filesystem" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/revision" + "github.com/fastly/cli/pkg/text" +) + +// RootCommand is the parent command for all subcommands in this package. +// It should be installed under the primary root command. +type RootCommand struct { + argparser.Base +} + +// CommandName is the string to be used to invoke this command. +const CommandName = "update" + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent argparser.Registerer, g *global.Data) *RootCommand { + var c RootCommand + c.Globals = g + c.CmdClause = parent.Command(CommandName, "Update the CLI to the latest version") + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, out io.Writer) error { + spinner, err := text.NewSpinner(out) + if err != nil { + return err + } + + var ( + current, latest semver.Version + shouldUpdate bool + ) + + err = spinner.Process("Updating versioning information", func(_ *text.SpinnerWrapper) error { + current, latest, shouldUpdate = Check(revision.AppVersion, c.Globals.Versioners.CLI) + return nil + }) + if err != nil { + return err + } + + text.Break(out) + text.Output(out, "Current version: %s", current) + text.Output(out, "Latest version: %s", latest) + text.Break(out) + + if !shouldUpdate { + text.Output(out, "No update required.") + return nil + } + + var downloadedBin string + err = spinner.Process("Fetching latest release", func(_ *text.SpinnerWrapper) error { + downloadedBin, err = c.Globals.Versioners.CLI.DownloadLatest() + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Current CLI version": current, + "Latest CLI version": latest, + }) + return fmt.Errorf("error downloading latest release: %w", err) + } + return nil + }) + if err != nil { + return err + } + defer os.RemoveAll(downloadedBin) + + var currentBin string + err = spinner.Process("Replacing binary", func(_ *text.SpinnerWrapper) error { + execPath, err := os.Executable() + if err != nil { + c.Globals.ErrLog.Add(err) + return fmt.Errorf("error determining executable path: %w", err) + } + + currentBin, err = filepath.Abs(execPath) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Executable path": execPath, + }) + return fmt.Errorf("error determining absolute target path: %w", err) + } + + // Windows does not permit replacing a running executable, however it will + // permit it if you first move the original executable. So we first move the + // running executable to a new location, then we move the executable that we + // downloaded to the same location as the original. + // I've also tested this approach on nix systems and it works fine. + // + // Reference: + // https://github.com/golang/go/issues/21997#issuecomment-331744930 + + backup := currentBin + ".bak" + if err := os.Rename(currentBin, backup); err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Executable (source)": downloadedBin, + "Executable (destination)": currentBin, + }) + return fmt.Errorf("error moving the current executable: %w", err) + } + + if err = os.Remove(backup); err != nil { + c.Globals.ErrLog.Add(err) + } + + // Move the downloaded binary to the same location as the current executable. + if err := os.Rename(downloadedBin, currentBin); err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Executable (source)": downloadedBin, + "Executable (destination)": currentBin, + }) + renameErr := err + + // Failing that we'll try to io.Copy downloaded binary to the current binary. + if err := filesystem.CopyFile(downloadedBin, currentBin); err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Executable (source)": downloadedBin, + "Executable (destination)": currentBin, + }) + return fmt.Errorf("error 'copying' latest binary in place: %w (following an error 'moving': %w)", err, renameErr) + } + + // G302 (CWE-276): Expect file permissions to be 0600 or less + // gosec flagged this: + // Disabling as the file was not executable without it and we need all users + // to be able to execute the binary. + // #nosec + err := os.Chmod(currentBin, 0o755) + if err != nil { + return fmt.Errorf("failed to modify permissions after 'copying' latest binary: %w", err) + } + } + return nil + }) + if err != nil { + return err + } + + text.Success(out, "\nUpdated %s to %s.", currentBin, latest) + return nil +} diff --git a/pkg/commands/user/create.go b/pkg/commands/user/create.go new file mode 100644 index 000000000..5d9527570 --- /dev/null +++ b/pkg/commands/user/create.go @@ -0,0 +1,69 @@ +package user + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// NewCreateCommand returns a usable command registered under the parent. +func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { + var c CreateCommand + c.CmdClause = parent.Command("create", "Create a user of the Fastly API and web interface").Alias("add") + c.Globals = g + + // Required. + c.CmdClause.Flag("login", "The login associated with the user (typically, an email address)").Action(c.login.Set).StringVar(&c.login.Value) + c.CmdClause.Flag("name", "The real life name of the user").Action(c.name.Set).StringVar(&c.name.Value) + + // Optional. + c.CmdClause.Flag("role", "The permissions role assigned to the user. Can be user, billing, engineer, or superuser").Action(c.role.Set).EnumVar(&c.role.Value, "user", "billing", "engineer", "superuser") + + return &c +} + +// CreateCommand calls the Fastly API to create an appropriate resource. +type CreateCommand struct { + argparser.Base + + login argparser.OptionalString + name argparser.OptionalString + role argparser.OptionalString +} + +// Exec invokes the application logic for the command. +func (c *CreateCommand) Exec(_ io.Reader, out io.Writer) error { + input := c.constructInput() + + r, err := c.Globals.APIClient.CreateUser(input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "User Login": c.login, + "User Name": c.name, + }) + return err + } + + text.Success(out, "Created user '%s' (role: %s)", fastly.ToValue(r.Name), fastly.ToValue(r.Role)) + return nil +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *CreateCommand) constructInput() *fastly.CreateUserInput { + var input fastly.CreateUserInput + if c.login.WasSet { + input.Login = &c.login.Value + } + if c.role.WasSet { + input.Role = &c.role.Value + } + if c.name.WasSet { + input.Name = &c.name.Value + } + + return &input +} diff --git a/pkg/commands/user/delete.go b/pkg/commands/user/delete.go new file mode 100644 index 000000000..c713304ec --- /dev/null +++ b/pkg/commands/user/delete.go @@ -0,0 +1,50 @@ +package user + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// NewDeleteCommand returns a usable command registered under the parent. +func NewDeleteCommand(parent argparser.Registerer, globals *global.Data) *DeleteCommand { + var c DeleteCommand + c.CmdClause = parent.Command("delete", "Delete a user of the Fastly API and web interface").Alias("remove") + c.Globals = globals + c.CmdClause.Flag("id", "Alphanumeric string identifying the user").Required().StringVar(&c.id) + return &c +} + +// DeleteCommand calls the Fastly API to delete an appropriate resource. +type DeleteCommand struct { + argparser.Base + + id string +} + +// Exec invokes the application logic for the command. +func (c *DeleteCommand) Exec(_ io.Reader, out io.Writer) error { + input := c.constructInput() + + err := c.Globals.APIClient.DeleteUser(input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "User ID": c.id, + }) + return err + } + + text.Success(out, "Deleted user (id: %s)", c.id) + return nil +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *DeleteCommand) constructInput() *fastly.DeleteUserInput { + var input fastly.DeleteUserInput + input.UserID = c.id + return &input +} diff --git a/pkg/commands/user/describe.go b/pkg/commands/user/describe.go new file mode 100644 index 000000000..bf0e3e48f --- /dev/null +++ b/pkg/commands/user/describe.go @@ -0,0 +1,112 @@ +package user + +import ( + "fmt" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" +) + +// NewDescribeCommand returns a usable command registered under the parent. +func NewDescribeCommand(parent argparser.Registerer, g *global.Data) *DescribeCommand { + var c DescribeCommand + c.CmdClause = parent.Command("describe", "Get a specific user of the Fastly API and web interface").Alias("get") + c.Globals = g + c.CmdClause.Flag("current", "Get the logged in user").BoolVar(&c.current) + c.CmdClause.Flag("id", "Alphanumeric string identifying the user").StringVar(&c.id) + c.RegisterFlagBool(c.JSONFlag()) // --json + return &c +} + +// DescribeCommand calls the Fastly API to describe an appropriate resource. +type DescribeCommand struct { + argparser.Base + argparser.JSONOutput + + current bool + id string +} + +// Exec invokes the application logic for the command. +func (c *DescribeCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + if c.current { + o, err := c.Globals.APIClient.GetCurrentUser() + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + c.print(out, o) + return nil + } + + input, err := c.constructInput() + if err != nil { + return err + } + + o, err := c.Globals.APIClient.GetUser(input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + c.print(out, o) + return nil +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *DescribeCommand) constructInput() (*fastly.GetUserInput, error) { + var input fastly.GetUserInput + + if c.id == "" { + return nil, fsterr.RemediationError{ + Inner: fmt.Errorf("error parsing arguments: must provide --id flag"), + Remediation: "Alternatively pass --current to validate the logged in user.", + } + } + input.UserID = c.id + + return &input, nil +} + +// print displays the information returned from the API. +func (c *DescribeCommand) print(out io.Writer, r *fastly.User) { + fmt.Fprintf(out, "\nID: %s\n", fastly.ToValue(r.UserID)) + fmt.Fprintf(out, "Login: %s\n", fastly.ToValue(r.Login)) + fmt.Fprintf(out, "Name: %s\n", fastly.ToValue(r.Name)) + fmt.Fprintf(out, "Role: %s\n", fastly.ToValue(r.Role)) + fmt.Fprintf(out, "Customer ID: %s\n", fastly.ToValue(r.CustomerID)) + fmt.Fprintf(out, "Email Hash: %s\n", fastly.ToValue(r.EmailHash)) + fmt.Fprintf(out, "Limit Services: %t\n", fastly.ToValue(r.LimitServices)) + fmt.Fprintf(out, "Locked: %t\n", fastly.ToValue(r.Locked)) + fmt.Fprintf(out, "Require New Password: %t\n", fastly.ToValue(r.RequireNewPassword)) + fmt.Fprintf(out, "Two Factor Auth Enabled: %t\n", fastly.ToValue(r.TwoFactorAuthEnabled)) + fmt.Fprintf(out, "Two Factor Setup Required: %t\n\n", fastly.ToValue(r.TwoFactorSetupRequired)) + + if r.CreatedAt != nil { + fmt.Fprintf(out, "Created at: %s\n", r.CreatedAt) + } + if r.UpdatedAt != nil { + fmt.Fprintf(out, "Updated at: %s\n", r.UpdatedAt) + } + if r.DeletedAt != nil { + fmt.Fprintf(out, "Deleted at: %s\n", r.DeletedAt) + } +} diff --git a/pkg/commands/user/doc.go b/pkg/commands/user/doc.go new file mode 100644 index 000000000..39a618560 --- /dev/null +++ b/pkg/commands/user/doc.go @@ -0,0 +1,3 @@ +// Package user contains commands to inspect and manipulate Fastly user +// accounts. +package user diff --git a/pkg/commands/user/list.go b/pkg/commands/user/list.go new file mode 100644 index 000000000..b18e1640f --- /dev/null +++ b/pkg/commands/user/list.go @@ -0,0 +1,125 @@ +package user + +import ( + "fmt" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// NewListCommand returns a usable command registered under the parent. +func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { + var c ListCommand + c.CmdClause = parent.Command("list", "List all users from a specified customer id") + c.Globals = g + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagCustomerIDName, + Description: argparser.FlagCustomerIDDesc, + Dst: &c.customerID.Value, + Action: c.customerID.Set, + }) + c.RegisterFlagBool(c.JSONFlag()) // --json + return &c +} + +// ListCommand calls the Fastly API to list appropriate resources. +type ListCommand struct { + argparser.Base + argparser.JSONOutput + + customerID argparser.OptionalCustomerID +} + +// Exec invokes the application logic for the command. +func (c *ListCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + if err := c.customerID.Parse(); err != nil { + return err + } + + input := c.constructInput() + + o, err := c.Globals.APIClient.ListCustomerUsers(input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Customer ID": c.customerID.Value, + }) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + if c.Globals.Verbose() { + c.printVerbose(out, o) + } else { + err = c.printSummary(out, o) + if err != nil { + return err + } + } + return nil +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *ListCommand) constructInput() *fastly.ListCustomerUsersInput { + var input fastly.ListCustomerUsersInput + + input.CustomerID = c.customerID.Value + + return &input +} + +// printVerbose displays the information returned from the API in a verbose +// format. +func (c *ListCommand) printVerbose(out io.Writer, us []*fastly.User) { + for _, u := range us { + fmt.Fprintf(out, "\nID: %s\n", fastly.ToValue(u.UserID)) + fmt.Fprintf(out, "Login: %s\n", fastly.ToValue(u.Login)) + fmt.Fprintf(out, "Name: %s\n", fastly.ToValue(u.Name)) + fmt.Fprintf(out, "Role: %s\n", fastly.ToValue(u.Role)) + fmt.Fprintf(out, "Customer ID: %s\n", fastly.ToValue(u.CustomerID)) + fmt.Fprintf(out, "Email Hash: %s\n", fastly.ToValue(u.EmailHash)) + fmt.Fprintf(out, "Limit Services: %t\n", fastly.ToValue(u.LimitServices)) + fmt.Fprintf(out, "Locked: %t\n", fastly.ToValue(u.Locked)) + fmt.Fprintf(out, "Require New Password: %t\n", fastly.ToValue(u.RequireNewPassword)) + fmt.Fprintf(out, "Two Factor Auth Enabled: %t\n", fastly.ToValue(u.TwoFactorAuthEnabled)) + fmt.Fprintf(out, "Two Factor Setup Required: %t\n\n", fastly.ToValue(u.TwoFactorSetupRequired)) + + if u.CreatedAt != nil { + fmt.Fprintf(out, "Created at: %s\n", u.CreatedAt) + } + if u.UpdatedAt != nil { + fmt.Fprintf(out, "Updated at: %s\n", u.UpdatedAt) + } + if u.DeletedAt != nil { + fmt.Fprintf(out, "Deleted at: %s\n", u.DeletedAt) + } + } +} + +// printSummary displays the information returned from the API in a summarised +// format. +func (c *ListCommand) printSummary(out io.Writer, us []*fastly.User) error { + t := text.NewTable(out) + t.AddHeader("LOGIN", "NAME", "ROLE", "LOCKED", "ID") + for _, u := range us { + t.AddLine( + fastly.ToValue(u.Login), + fastly.ToValue(u.Name), + fastly.ToValue(u.Role), + fastly.ToValue(u.Locked), + fastly.ToValue(u.UserID), + ) + } + t.Print() + return nil +} diff --git a/pkg/commands/user/root.go b/pkg/commands/user/root.go new file mode 100644 index 000000000..9ed3efb13 --- /dev/null +++ b/pkg/commands/user/root.go @@ -0,0 +1,31 @@ +package user + +import ( + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" +) + +// RootCommand is the parent command for all subcommands in this package. +// It should be installed under the primary root command. +type RootCommand struct { + argparser.Base + // no flags +} + +// CommandName is the string to be used to invoke this command. +const CommandName = "user" + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent argparser.Registerer, g *global.Data) *RootCommand { + var c RootCommand + c.Globals = g + c.CmdClause = parent.Command(CommandName, "Manipulate users of the Fastly API and web interface") + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { + panic("unreachable") +} diff --git a/pkg/commands/user/update.go b/pkg/commands/user/update.go new file mode 100644 index 000000000..d5b951711 --- /dev/null +++ b/pkg/commands/user/update.go @@ -0,0 +1,106 @@ +package user + +import ( + "fmt" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// NewUpdateCommand returns a usable command registered under the parent. +func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { + var c UpdateCommand + c.CmdClause = parent.Command("update", "Update a user of the Fastly API and web interface") + c.Globals = g + c.CmdClause.Flag("id", "Alphanumeric string identifying the user").StringVar(&c.id) + c.CmdClause.Flag("login", "The login associated with the user (typically, an email address)").StringVar(&c.login) + c.CmdClause.Flag("name", "The real life name of the user").StringVar(&c.name) + c.CmdClause.Flag("password-reset", "Requests a password reset for the specified user").BoolVar(&c.reset) + c.CmdClause.Flag("role", "The permissions role assigned to the user. Can be user, billing, engineer, or superuser").EnumVar(&c.role, "user", "billing", "engineer", "superuser") + + return &c +} + +// UpdateCommand calls the Fastly API to update an appropriate resource. +type UpdateCommand struct { + argparser.Base + + id string + login string + name string + reset bool + role string +} + +// Exec invokes the application logic for the command. +func (c *UpdateCommand) Exec(_ io.Reader, out io.Writer) error { + if c.reset { + input, err := c.constructInputReset() + if err != nil { + return err + } + + err = c.Globals.APIClient.ResetUserPassword(input) + if err != nil { + return err + } + + text.Success(out, "Reset user password (login: %s)", c.login) + return nil + } + + input, err := c.constructInput() + if err != nil { + return err + } + + r, err := c.Globals.APIClient.UpdateUser(input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "User ID": c.id, + }) + return err + } + + text.Success(out, "Updated user '%s' (role: %s)", fastly.ToValue(r.Name), fastly.ToValue(r.Role)) + return nil +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *UpdateCommand) constructInput() (*fastly.UpdateUserInput, error) { + var input fastly.UpdateUserInput + + if c.id == "" { + return nil, fmt.Errorf("error parsing arguments: must provide --id flag") + } + input.UserID = c.id + + if c.name == "" && c.role == "" { + return nil, fmt.Errorf("error parsing arguments: must provide either the --name or --role with the --id flag") + } + + if c.name != "" { + input.Name = &c.name + } + if c.role != "" { + input.Role = &c.role + } + + return &input, nil +} + +// constructInputReset transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *UpdateCommand) constructInputReset() (*fastly.ResetUserPasswordInput, error) { + var input fastly.ResetUserPasswordInput + + if c.login == "" { + return nil, fmt.Errorf("error parsing arguments: must provide --login when requesting a password reset") + } + input.Login = c.login + + return &input, nil +} diff --git a/pkg/commands/user/user_test.go b/pkg/commands/user/user_test.go new file mode 100644 index 000000000..3a7f4e4e5 --- /dev/null +++ b/pkg/commands/user/user_test.go @@ -0,0 +1,333 @@ +package user_test + +import ( + "fmt" + "testing" + + "github.com/fastly/go-fastly/v10/fastly" + + root "github.com/fastly/cli/pkg/commands/user" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" +) + +func TestUserCreate(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate CreateUser API error", + API: mock.API{ + CreateUserFn: func(_ *fastly.CreateUserInput) (*fastly.User, error) { + return nil, testutil.Err + }, + }, + Args: "--login foo@example.com --name foobar", + WantError: testutil.Err.Error(), + }, + { + Name: "validate CreateUser API success", + API: mock.API{ + CreateUserFn: func(i *fastly.CreateUserInput) (*fastly.User, error) { + return &fastly.User{ + Name: i.Name, + Role: fastly.ToPointer("user"), + }, nil + }, + }, + Args: "--login foo@example.com --name foobar", + WantOutput: "Created user 'foobar' (role: user)", + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, "create"}, scenarios) +} + +func TestUserDelete(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --id flag", + WantError: "error parsing arguments: required flag --id not provided", + }, + { + Name: "validate DeleteUser API error", + API: mock.API{ + DeleteUserFn: func(_ *fastly.DeleteUserInput) error { + return testutil.Err + }, + }, + Args: "--id foo123", + WantError: testutil.Err.Error(), + }, + { + Name: "validate DeleteUser API success", + API: mock.API{ + DeleteUserFn: func(_ *fastly.DeleteUserInput) error { + return nil + }, + }, + Args: "--id foo123", + WantOutput: "Deleted user (id: foo123)", + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, "delete"}, scenarios) +} + +func TestUserDescribe(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --id flag", + WantError: "error parsing arguments: must provide --id flag", + }, + { + Name: "validate GetUser API error", + API: mock.API{ + GetUserFn: func(_ *fastly.GetUserInput) (*fastly.User, error) { + return nil, testutil.Err + }, + }, + Args: "--id 123", + WantError: testutil.Err.Error(), + }, + { + Name: "validate GetCurrentUser API error", + API: mock.API{ + GetCurrentUserFn: func() (*fastly.User, error) { + return nil, testutil.Err + }, + }, + Args: "--current", + WantError: testutil.Err.Error(), + }, + { + Name: "validate GetUser API success", + API: mock.API{ + GetUserFn: getUser, + }, + Args: "--id 123", + WantOutput: describeUserOutput(), + }, + { + Name: "validate GetCurrentUser API success", + API: mock.API{ + GetCurrentUserFn: getCurrentUser, + }, + Args: "--current", + WantOutput: describeCurrentUserOutput(), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, "describe"}, scenarios) +} + +func TestUserList(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --customer-id flag", + WantError: "error reading customer ID: no customer ID found", + }, + { + Name: "validate ListUsers API error", + API: mock.API{ + ListCustomerUsersFn: func(_ *fastly.ListCustomerUsersInput) ([]*fastly.User, error) { + return nil, testutil.Err + }, + }, + Args: "--customer-id abc", + WantError: testutil.Err.Error(), + }, + { + Name: "validate ListUsers API success", + API: mock.API{ + ListCustomerUsersFn: listUsers, + }, + Args: "--customer-id abc", + WantOutput: listOutput(), + }, + { + Name: "validate ListUsers API success with verbose mode", + API: mock.API{ + ListCustomerUsersFn: listUsers, + }, + Args: "--customer-id abc --verbose", + WantOutput: listVerboseOutput(), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, "list"}, scenarios) +} + +func TestUserUpdate(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --id flag", + WantError: "error parsing arguments: must provide --id flag", + }, + { + Name: "validate missing --name and --role flags", + Args: "--id 123", + WantError: "error parsing arguments: must provide either the --name or --role with the --id flag", + }, + { + Name: "validate missing --login flag with --password-reset", + Args: "--password-reset", + WantError: "error parsing arguments: must provide --login when requesting a password reset", + }, + { + Name: "validate invalid --role value", + Args: "--id 123 --role foobar", + WantError: "error parsing arguments: enum value must be one of user,billing,engineer,superuser, got 'foobar'", + }, + { + Name: "validate UpdateUser API error", + API: mock.API{ + UpdateUserFn: func(_ *fastly.UpdateUserInput) (*fastly.User, error) { + return nil, testutil.Err + }, + }, + Args: "--id 123 --name foo", + WantError: testutil.Err.Error(), + }, + { + Name: "validate ResetUserPassword API error", + API: mock.API{ + ResetUserPasswordFn: func(_ *fastly.ResetUserPasswordInput) error { + return testutil.Err + }, + }, + Args: "--id 123 --login foo@example.com --password-reset", + WantError: testutil.Err.Error(), + }, + { + Name: "validate UpdateUser API success", + API: mock.API{ + UpdateUserFn: func(i *fastly.UpdateUserInput) (*fastly.User, error) { + return &fastly.User{ + UserID: fastly.ToPointer(i.UserID), + Name: i.Name, + Role: i.Role, + }, nil + }, + }, + Args: "--id 123 --name foo --role engineer", + WantOutput: "Updated user 'foo' (role: engineer)", + }, + { + Name: "validate ResetUserPassword API success", + API: mock.API{ + ResetUserPasswordFn: func(_ *fastly.ResetUserPasswordInput) error { + return nil + }, + }, + Args: "--id 123 --login foo@example.com --password-reset", + WantOutput: "Reset user password (login: foo@example.com)", + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, "update"}, scenarios) +} + +func getUser(i *fastly.GetUserInput) (*fastly.User, error) { + t := testutil.Date + + return &fastly.User{ + UserID: fastly.ToPointer(i.UserID), + Login: fastly.ToPointer("foo@example.com"), + Name: fastly.ToPointer("foo"), + Role: fastly.ToPointer("user"), + CustomerID: fastly.ToPointer("abc"), + EmailHash: fastly.ToPointer("example-hash"), + LimitServices: fastly.ToPointer(true), + Locked: fastly.ToPointer(true), + RequireNewPassword: fastly.ToPointer(true), + TwoFactorAuthEnabled: fastly.ToPointer(true), + TwoFactorSetupRequired: fastly.ToPointer(true), + CreatedAt: &t, + DeletedAt: &t, + UpdatedAt: &t, + }, nil +} + +func getCurrentUser() (*fastly.User, error) { + t := testutil.Date + + return &fastly.User{ + UserID: fastly.ToPointer("current123"), + Login: fastly.ToPointer("bar@example.com"), + Name: fastly.ToPointer("bar"), + Role: fastly.ToPointer("superuser"), + CustomerID: fastly.ToPointer("abc"), + EmailHash: fastly.ToPointer("example-hash2"), + LimitServices: fastly.ToPointer(false), + Locked: fastly.ToPointer(false), + RequireNewPassword: fastly.ToPointer(false), + TwoFactorAuthEnabled: fastly.ToPointer(false), + TwoFactorSetupRequired: fastly.ToPointer(false), + CreatedAt: &t, + DeletedAt: &t, + UpdatedAt: &t, + }, nil +} + +func listUsers(_ *fastly.ListCustomerUsersInput) ([]*fastly.User, error) { + user, _ := getUser(&fastly.GetUserInput{UserID: "123"}) + userCurrent, _ := getCurrentUser() + vs := []*fastly.User{ + user, + userCurrent, + } + return vs, nil +} + +func describeUserOutput() string { + return ` +ID: 123 +Login: foo@example.com +Name: foo +Role: user +Customer ID: abc +Email Hash: example-hash +Limit Services: true +Locked: true +Require New Password: true +Two Factor Auth Enabled: true +Two Factor Setup Required: true + +Created at: 2021-06-15 23:00:00 +0000 UTC +Updated at: 2021-06-15 23:00:00 +0000 UTC +Deleted at: 2021-06-15 23:00:00 +0000 UTC +` +} + +func describeCurrentUserOutput() string { + return ` +ID: current123 +Login: bar@example.com +Name: bar +Role: superuser +Customer ID: abc +Email Hash: example-hash2 +Limit Services: false +Locked: false +Require New Password: false +Two Factor Auth Enabled: false +Two Factor Setup Required: false + +Created at: 2021-06-15 23:00:00 +0000 UTC +Updated at: 2021-06-15 23:00:00 +0000 UTC +Deleted at: 2021-06-15 23:00:00 +0000 UTC +` +} + +func listOutput() string { + return `LOGIN NAME ROLE LOCKED ID +foo@example.com foo user true 123 +bar@example.com bar superuser false current123 +` +} + +func listVerboseOutput() string { + return fmt.Sprintf(`Fastly API endpoint: https://api.fastly.com +Fastly API token provided via config file (profile: user) + +%s%s`, describeUserOutput(), describeCurrentUserOutput()) +} diff --git a/pkg/commands/vcl/condition/condition_test.go b/pkg/commands/vcl/condition/condition_test.go new file mode 100644 index 000000000..93b8553a6 --- /dev/null +++ b/pkg/commands/vcl/condition/condition_test.go @@ -0,0 +1,335 @@ +package condition_test + +import ( + "errors" + "strings" + "testing" + + "github.com/fastly/go-fastly/v10/fastly" + + root "github.com/fastly/cli/pkg/commands/vcl" + sub "github.com/fastly/cli/pkg/commands/vcl/condition" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" +) + +func TestConditionCreate(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Args: "--version 1", + WantError: "error reading service: no service ID found", + }, + { + Args: "--service-id 123 --version 1 --name always_false --statement false --type REQUEST --autoclone", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + CreateConditionFn: createConditionOK, + }, + WantOutput: "Created condition always_false (service 123 version 4)", + }, + { + Args: "--service-id 123 --version 1 --name always_false --statement false --type REQUEST --priority 10 --autoclone", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + CreateConditionFn: createConditionError, + }, + WantError: errTest.Error(), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "create"}, scenarios) +} + +func TestConditionDelete(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Args: "--service-id 123 --version 1", + WantError: "error parsing arguments: required flag --name not provided", + }, + { + Args: "--service-id 123 --version 1 --name always_false --autoclone", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + DeleteConditionFn: deleteConditionError, + }, + WantError: errTest.Error(), + }, + { + Args: "--service-id 123 --version 1 --name always_false --autoclone", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + DeleteConditionFn: deleteConditionOK, + }, + WantOutput: "Deleted condition always_false (service 123 version 4)", + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "delete"}, scenarios) +} + +func TestConditionUpdate(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Args: "--service-id 123 --version 1 --new-name false_always --comment ", + WantError: "error parsing arguments: required flag --name not provided", + }, + { + Args: "--service-id 123 --version 1 --name always_false --autoclone", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + UpdateConditionFn: updateConditionOK, + }, + WantError: "error parsing arguments: must provide either --new-name, --statement, --type or --priority to update condition", + }, + { + Args: "--service-id 123 --version 1 --name always_false --new-name false_always --autoclone", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + UpdateConditionFn: updateConditionError, + }, + WantError: errTest.Error(), + }, + { + Args: "--service-id 123 --version 1 --name always_false --new-name false_always --autoclone", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + UpdateConditionFn: updateConditionOK, + }, + WantOutput: "Updated condition false_always (service 123 version 4)", + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "update"}, scenarios) +} + +func TestConditionDescribe(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Args: "--service-id 123 --version 1", + WantError: "error parsing arguments: required flag --name not provided", + }, + { + Args: "--service-id 123 --version 1 --name always_false", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + GetConditionFn: getConditionError, + }, + WantError: errTest.Error(), + }, + { + Args: "--service-id 123 --version 1 --name always_false", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + GetConditionFn: getConditionOK, + }, + WantOutput: describeConditionOutput, + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "describe"}, scenarios) +} + +func TestConditionList(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Args: "--service-id 123 --version 1", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListConditionsFn: listConditionsOK, + }, + WantOutput: listConditionsShortOutput, + }, + { + Args: "--service-id 123 --version 1 --verbose", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListConditionsFn: listConditionsOK, + }, + WantOutput: listConditionsVerboseOutput, + }, + { + Args: "--service-id 123 --version 1 -v", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListConditionsFn: listConditionsOK, + }, + WantOutput: listConditionsVerboseOutput, + }, + { + Args: "--verbose --service-id 123 --version 1", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListConditionsFn: listConditionsOK, + }, + WantOutput: listConditionsVerboseOutput, + }, + { + Args: "-v --service-id 123 --version 1", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListConditionsFn: listConditionsOK, + }, + WantOutput: listConditionsVerboseOutput, + }, + { + Args: "--service-id 123 --version 1", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListConditionsFn: listConditionsError, + }, + WantError: errTest.Error(), + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "list"}, scenarios) +} + +var describeConditionOutput = "\n" + strings.TrimSpace(` +Service ID: 123 +Version: 1 +Name: always_false +Statement: false +Type: CACHE +Priority: 10 +`) + "\n" + +var listConditionsShortOutput = strings.TrimSpace(` +SERVICE VERSION NAME STATEMENT TYPE PRIORITY +123 1 always_false_request false REQUEST 10 +123 1 always_false_cache false CACHE 10 +`) + "\n" + +var listConditionsVerboseOutput = strings.TrimSpace(` +Fastly API endpoint: https://api.fastly.com +Fastly API token provided via config file (profile: user) + +Service ID (via --service-id): 123 + +Version: 1 + Condition 1/2 + Name: always_false_request + Statement: false + Type: REQUEST + Priority: 10 + Condition 2/2 + Name: always_false_cache + Statement: false + Type: CACHE + Priority: 10 +`) + "\n\n" + +var errTest = errors.New("fixture error") + +func createConditionOK(i *fastly.CreateConditionInput) (*fastly.Condition, error) { + priority := 10 + if i.Priority != nil { + priority = *i.Priority + } + + conditionType := "REQUEST" + if i.Type != nil { + conditionType = *i.Type + } + + return &fastly.Condition{ + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: i.Name, + Statement: i.Statement, + Type: fastly.ToPointer(conditionType), + Priority: fastly.ToPointer(priority), + }, nil +} + +func createConditionError(_ *fastly.CreateConditionInput) (*fastly.Condition, error) { + return nil, errTest +} + +func deleteConditionOK(_ *fastly.DeleteConditionInput) error { + return nil +} + +func deleteConditionError(_ *fastly.DeleteConditionInput) error { + return errTest +} + +func updateConditionOK(i *fastly.UpdateConditionInput) (*fastly.Condition, error) { + priority := 10 + if i.Priority != nil { + priority = *i.Priority + } + + conditionType := "REQUEST" + if i.Type != nil { + conditionType = *i.Type + } + + statement := "false" + if i.Statement != nil { + statement = *i.Type + } + + return &fastly.Condition{ + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer(i.Name), + Statement: fastly.ToPointer(statement), + Type: fastly.ToPointer(conditionType), + Priority: fastly.ToPointer(priority), + }, nil +} + +func updateConditionError(_ *fastly.UpdateConditionInput) (*fastly.Condition, error) { + return nil, errTest +} + +func getConditionOK(i *fastly.GetConditionInput) (*fastly.Condition, error) { + priority := 10 + conditionType := "CACHE" + statement := "false" + + return &fastly.Condition{ + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer(i.Name), + Statement: fastly.ToPointer(statement), + Type: fastly.ToPointer(conditionType), + Priority: fastly.ToPointer(priority), + }, nil +} + +func getConditionError(_ *fastly.GetConditionInput) (*fastly.Condition, error) { + return nil, errTest +} + +func listConditionsOK(i *fastly.ListConditionsInput) ([]*fastly.Condition, error) { + return []*fastly.Condition{ + { + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("always_false_request"), + Statement: fastly.ToPointer("false"), + Type: fastly.ToPointer("REQUEST"), + Priority: fastly.ToPointer(10), + }, + { + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Name: fastly.ToPointer("always_false_cache"), + Statement: fastly.ToPointer("false"), + Type: fastly.ToPointer("CACHE"), + Priority: fastly.ToPointer(10), + }, + }, nil +} + +func listConditionsError(_ *fastly.ListConditionsInput) ([]*fastly.Condition, error) { + return nil, errTest +} diff --git a/pkg/commands/vcl/condition/create.go b/pkg/commands/vcl/condition/create.go new file mode 100644 index 000000000..884d738a1 --- /dev/null +++ b/pkg/commands/vcl/condition/create.go @@ -0,0 +1,132 @@ +package condition + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// ConditionTypes are the allowed input values for the --type flag. +// Reference: https://www.fastly.com/documentation/reference/api/vcl-services/condition +var ConditionTypes = []string{"REQUEST", "CACHE", "RESPONSE", "PREFETCH"} + +// CreateCommand calls the Fastly API to create an appropriate resource. +type CreateCommand struct { + argparser.Base + + // Required. + serviceVersion argparser.OptionalServiceVersion + + // Optional. + autoClone argparser.OptionalAutoClone + conditionType argparser.OptionalString + name argparser.OptionalString + priority argparser.OptionalInt + serviceName argparser.OptionalServiceNameID + statement argparser.OptionalString +} + +// NewCreateCommand returns a usable command registered under the parent. +func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { + c := CreateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("create", "Create a condition on a Fastly service version").Alias("add") + + // Required flags + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional flags + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.autoClone.Set, + Dst: &c.autoClone.Value, + }) + c.CmdClause.Flag("name", "Condition name").Short('n').Action(c.name.Set).StringVar(&c.name.Value) + c.CmdClause.Flag("priority", "Condition priority").Action(c.priority.Set).IntVar(&c.priority.Value) + c.CmdClause.Flag("statement", "Condition statement").Action(c.statement.Set).StringVar(&c.statement.Value) + c.CmdClause.Flag("type", "Condition type").HintOptions(ConditionTypes...).Action(c.conditionType.Set).EnumVar(&c.conditionType.Value, ConditionTypes...) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + + return &c +} + +// Exec invokes the application logic for the command. +func (c *CreateCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.autoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + input := fastly.CreateConditionInput{ + ServiceID: serviceID, + ServiceVersion: fastly.ToValue(serviceVersion.Number), + } + + if c.name.WasSet { + input.Name = &c.name.Value + } + if c.statement.WasSet { + input.Statement = &c.statement.Value + } + if c.conditionType.WasSet { + input.Type = &c.conditionType.Value + } + if c.priority.WasSet { + input.Priority = &c.priority.Value + } + r, err := c.Globals.APIClient.CreateCondition(&input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fastly.ToValue(serviceVersion.Number), + }) + return err + } + + text.Success(out, + "Created condition %s (service %s version %d)", + fastly.ToValue(r.Name), + fastly.ToValue(r.ServiceID), + fastly.ToValue(r.ServiceVersion), + ) + return nil +} diff --git a/pkg/commands/vcl/condition/delete.go b/pkg/commands/vcl/condition/delete.go new file mode 100644 index 000000000..9726df2d4 --- /dev/null +++ b/pkg/commands/vcl/condition/delete.go @@ -0,0 +1,100 @@ +package condition + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// DeleteCommand calls the Fastly API to delete an appropriate resource. +type DeleteCommand struct { + argparser.Base + name string + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion + autoClone argparser.OptionalAutoClone +} + +// NewDeleteCommand returns a usable command registered under the parent. +func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { + c := DeleteCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("delete", "Delete a condition on a Fastly service version").Alias("remove") + + // Required flags + c.CmdClause.Flag("name", "Condition name").Short('n').Required().StringVar(&c.name) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional flags + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.autoClone.Set, + Dst: &c.autoClone.Value, + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + + return &c +} + +// Exec invokes the application logic for the command. +func (c *DeleteCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.autoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + var input fastly.DeleteConditionInput + input.ServiceID = serviceID + input.ServiceVersion = fastly.ToValue(serviceVersion.Number) + input.Name = c.name + + if err := c.Globals.APIClient.DeleteCondition(&input); err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fastly.ToValue(serviceVersion.Number), + }) + return err + } + + text.Success(out, "Deleted condition %s (service %s version %d)", c.name, serviceID, fastly.ToValue(serviceVersion.Number)) + return nil +} diff --git a/pkg/commands/vcl/condition/describe.go b/pkg/commands/vcl/condition/describe.go new file mode 100644 index 000000000..fa2738a41 --- /dev/null +++ b/pkg/commands/vcl/condition/describe.go @@ -0,0 +1,107 @@ +package condition + +import ( + "fmt" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" +) + +// DescribeCommand calls the Fastly API to describe an appropriate resource. +type DescribeCommand struct { + argparser.Base + argparser.JSONOutput + name string + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion +} + +// NewDescribeCommand returns a usable command registered under the parent. +func NewDescribeCommand(parent argparser.Registerer, g *global.Data) *DescribeCommand { + var c DescribeCommand + c.CmdClause = parent.Command("describe", "Show detailed information about a condition on a Fastly service version").Alias("get") + c.Globals = g + + // Required flags + c.CmdClause.Flag("name", "Name of condition").Short('n').Required().StringVar(&c.name) + + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional flags + c.RegisterFlagBool(c.JSONFlag()) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + + return &c +} + +// Exec invokes the application logic for the command. +func (c *DescribeCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return errors.ErrInvalidVerboseJSONCombo + } + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + var input fastly.GetConditionInput + input.ServiceID = serviceID + input.ServiceVersion = fastly.ToValue(serviceVersion.Number) + input.Name = c.name + + r, err := c.Globals.APIClient.GetCondition(&input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fastly.ToValue(serviceVersion.Number), + }) + return err + } + + if ok, err := c.WriteJSON(out, r); ok { + return err + } + + if !c.Globals.Verbose() { + fmt.Fprintf(out, "\nService ID: %s\n", fastly.ToValue(r.ServiceID)) + } + fmt.Fprintf(out, "Version: %d\n", fastly.ToValue(r.ServiceVersion)) + fmt.Fprintf(out, "Name: %s\n", fastly.ToValue(r.Name)) + fmt.Fprintf(out, "Statement: %s\n", fastly.ToValue(r.Statement)) + fmt.Fprintf(out, "Type: %s\n", fastly.ToValue(r.Type)) + fmt.Fprintf(out, "Priority: %d\n", fastly.ToValue(r.Priority)) + + return nil +} diff --git a/pkg/commands/vcl/condition/doc.go b/pkg/commands/vcl/condition/doc.go new file mode 100644 index 000000000..61714ecf7 --- /dev/null +++ b/pkg/commands/vcl/condition/doc.go @@ -0,0 +1,2 @@ +// Package condition contains commands to inspect and manipulate Fastly service condition. +package condition diff --git a/pkg/commands/vcl/condition/list.go b/pkg/commands/vcl/condition/list.go new file mode 100644 index 000000000..9133f1293 --- /dev/null +++ b/pkg/commands/vcl/condition/list.go @@ -0,0 +1,123 @@ +package condition + +import ( + "fmt" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// ListCommand calls the Fastly API to list appropriate resources. +type ListCommand struct { + argparser.Base + argparser.JSONOutput + + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion +} + +// NewListCommand returns a usable command registered under the parent. +func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { + var c ListCommand + c.CmdClause = parent.Command("list", "List condition on a Fastly service version") + c.Globals = g + + // Required flags + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional Flags + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + + return &c +} + +// Exec invokes the application logic for the command. +func (c *ListCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return errors.ErrInvalidVerboseJSONCombo + } + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + var input fastly.ListConditionsInput + + input.ServiceID = serviceID + input.ServiceVersion = fastly.ToValue(serviceVersion.Number) + + o, err := c.Globals.APIClient.ListConditions(&input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fastly.ToValue(serviceVersion.Number), + }) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + if !c.Globals.Verbose() { + tw := text.NewTable(out) + tw.AddHeader("SERVICE", "VERSION", "NAME", "STATEMENT", "TYPE", "PRIORITY") + for _, r := range o { + tw.AddLine( + fastly.ToValue(r.ServiceID), + fastly.ToValue(r.ServiceVersion), + fastly.ToValue(r.Name), + fastly.ToValue(r.Statement), + fastly.ToValue(r.Type), + fastly.ToValue(r.Priority), + ) + } + tw.Print() + return nil + } + + fmt.Fprintf(out, "Version: %d\n", input.ServiceVersion) + for i, r := range o { + fmt.Fprintf(out, "\tCondition %d/%d\n", i+1, len(o)) + fmt.Fprintf(out, "\t\tName: %s\n", fastly.ToValue(r.Name)) + fmt.Fprintf(out, "\t\tStatement: %v\n", fastly.ToValue(r.Statement)) + fmt.Fprintf(out, "\t\tType: %v\n", fastly.ToValue(r.Type)) + fmt.Fprintf(out, "\t\tPriority: %v\n", fastly.ToValue(r.Priority)) + } + fmt.Fprintln(out) + + return nil +} diff --git a/pkg/commands/vcl/condition/root.go b/pkg/commands/vcl/condition/root.go new file mode 100644 index 000000000..8ea392e43 --- /dev/null +++ b/pkg/commands/vcl/condition/root.go @@ -0,0 +1,31 @@ +package condition + +import ( + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" +) + +// RootCommand is the parent command for all subcommands in this package. +// It should be installed under the primary root command. +type RootCommand struct { + argparser.Base + // no flags +} + +// CommandName is the string to be used to invoke this command. +const CommandName = "condition" + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent argparser.Registerer, g *global.Data) *RootCommand { + var c RootCommand + c.Globals = g + c.CmdClause = parent.Command(CommandName, "Manipulate Fastly service version conditions") + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { + panic("unreachable") +} diff --git a/pkg/commands/vcl/condition/update.go b/pkg/commands/vcl/condition/update.go new file mode 100644 index 000000000..21ef36ebb --- /dev/null +++ b/pkg/commands/vcl/condition/update.go @@ -0,0 +1,135 @@ +package condition + +import ( + "fmt" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// UpdateCommand calls the Fastly API to update an appropriate resource. +type UpdateCommand struct { + argparser.Base + input fastly.UpdateConditionInput + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion + autoClone argparser.OptionalAutoClone + + newName argparser.OptionalString + statement argparser.OptionalString + conditionType argparser.OptionalString + priority argparser.OptionalInt + comment argparser.OptionalString +} + +// NewUpdateCommand returns a usable command registered under the parent. +func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { + var c UpdateCommand + c.CmdClause = parent.Command("update", "Update a condition on a Fastly service version") + c.Globals = g + + // Required flags + c.CmdClause.Flag("name", "Domain name").Short('n').Required().StringVar(&c.input.Name) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional flags + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.autoClone.Set, + Dst: &c.autoClone.Value, + }) + c.CmdClause.Flag("new-name", "New condition name").Action(c.newName.Set).StringVar(&c.newName.Value) + c.CmdClause.Flag("priority", "Condition priority").Action(c.priority.Set).IntVar(&c.priority.Value) + c.CmdClause.Flag("statement", "Condition statement").Action(c.statement.Set).StringVar(&c.statement.Value) + c.CmdClause.Flag("type", "Condition type").Action(c.conditionType.Set).StringVar(&c.conditionType.Value) + c.CmdClause.Flag("comment", "Condition comment").Action(c.comment.Set).StringVar(&c.comment.Value) + + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + + return &c +} + +// Exec invokes the application logic for the command. +func (c *UpdateCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.autoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + c.input.ServiceID = serviceID + c.input.ServiceVersion = fastly.ToValue(serviceVersion.Number) + + // If no argument are provided, error with useful message. + if !c.newName.WasSet && !c.priority.WasSet && !c.statement.WasSet && !c.conditionType.WasSet { + return fmt.Errorf("error parsing arguments: must provide either --new-name, --statement, --type or --priority to update condition") + } + + if c.newName.WasSet { + c.input.Name = c.newName.Value + } + if c.priority.WasSet { + c.input.Priority = &c.priority.Value + } + if c.conditionType.WasSet { + c.input.Type = &c.conditionType.Value + } + if c.statement.WasSet { + c.input.Statement = &c.statement.Value + } + if c.comment.WasSet { + c.input.Statement = &c.comment.Value + } + + r, err := c.Globals.APIClient.UpdateCondition(&c.input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fastly.ToValue(serviceVersion.Number), + }) + return err + } + + text.Success(out, + "Updated condition %s (service %s version %d)", + fastly.ToValue(r.Name), + fastly.ToValue(r.ServiceID), + fastly.ToValue(r.ServiceVersion), + ) + return nil +} diff --git a/pkg/commands/vcl/custom/create.go b/pkg/commands/vcl/custom/create.go new file mode 100644 index 000000000..f423bc0ae --- /dev/null +++ b/pkg/commands/vcl/custom/create.go @@ -0,0 +1,127 @@ +package custom + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// NewCreateCommand returns a usable command registered under the parent. +func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { + c := CreateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("create", "Upload a VCL for a particular service and version").Alias("add") + + // Required. + c.CmdClause.Flag("content", "VCL passed as file path or content, e.g. $(< main.vcl)").Action(c.content.Set).StringVar(&c.content.Value) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.autoClone.Set, + Dst: &c.autoClone.Value, + }) + c.CmdClause.Flag("main", "Whether the VCL is the 'main' entrypoint").Action(c.main.Set).BoolVar(&c.main.Value) + c.CmdClause.Flag("name", "The name of the VCL").Action(c.name.Set).StringVar(&c.name.Value) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + + return &c +} + +// CreateCommand calls the Fastly API to create an appropriate resource. +type CreateCommand struct { + argparser.Base + + autoClone argparser.OptionalAutoClone + content argparser.OptionalString + main argparser.OptionalBool + name argparser.OptionalString + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion +} + +// Exec invokes the application logic for the command. +func (c *CreateCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.autoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + input := c.constructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + + v, err := c.Globals.APIClient.CreateVCL(input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fastly.ToValue(serviceVersion.Number), + }) + return err + } + + text.Success(out, + "Created custom VCL '%s' (service: %s, version: %d, main: %t)", + fastly.ToValue(v.Name), + fastly.ToValue(v.ServiceID), + fastly.ToValue(v.ServiceVersion), + fastly.ToValue(v.Main), + ) + return nil +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *CreateCommand) constructInput(serviceID string, serviceVersion int) *fastly.CreateVCLInput { + input := fastly.CreateVCLInput{ + ServiceID: serviceID, + ServiceVersion: serviceVersion, + } + if c.name.WasSet { + input.Name = &c.name.Value + } + if c.content.WasSet { + input.Content = fastly.ToPointer(argparser.Content(c.content.Value)) + } + if c.main.WasSet { + input.Main = fastly.ToPointer(c.main.Value) + } + return &input +} diff --git a/pkg/commands/vcl/custom/custom_test.go b/pkg/commands/vcl/custom/custom_test.go new file mode 100644 index 000000000..616cc0767 --- /dev/null +++ b/pkg/commands/vcl/custom/custom_test.go @@ -0,0 +1,501 @@ +package custom_test + +import ( + "testing" + + "github.com/fastly/go-fastly/v10/fastly" + + root "github.com/fastly/cli/pkg/commands/vcl" + sub "github.com/fastly/cli/pkg/commands/vcl/custom" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" +) + +func TestVCLCustomCreate(t *testing.T) { + var content string + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --autoclone flag with 'active' service", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + }, + Args: "--content ./testdata/example.vcl --name foo --service-id 123 --version 1", + WantError: "service version 1 is active", + }, + { + Name: "validate missing --autoclone flag with 'locked' service", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + }, + Args: "--content ./testdata/example.vcl --name foo --service-id 123 --version 2", + WantError: "service version 2 is locked", + }, + { + Name: "validate CreateVCL API error", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + CreateVCLFn: func(_ *fastly.CreateVCLInput) (*fastly.VCL, error) { + return nil, testutil.Err + }, + }, + Args: "--content ./testdata/example.vcl --name foo --service-id 123 --version 3", + WantError: testutil.Err.Error(), + }, + { + Name: "validate CreateVCL API success for non-main VCL", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + CreateVCLFn: func(i *fastly.CreateVCLInput) (*fastly.VCL, error) { + // Track the contents parsed + content = *i.Content + if i.Content == nil { + i.Content = fastly.ToPointer("") + } + if i.Main == nil { + b := false + i.Main = &b + } + if i.Name == nil { + i.Name = fastly.ToPointer("") + } + return &fastly.VCL{ + Content: i.Content, + Main: i.Main, + Name: i.Name, + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + }, nil + }, + }, + Args: "--content ./testdata/example.vcl --name foo --service-id 123 --version 3", + WantOutput: "Created custom VCL 'foo' (service: 123, version: 3, main: false)", + PathContentFlag: &testutil.PathContentFlag{Flag: "content", Fixture: "example.vcl", Content: func() string { return content }}, + }, + { + Name: "validate CreateVCL API success for main VCL", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + CreateVCLFn: func(i *fastly.CreateVCLInput) (*fastly.VCL, error) { + // Track the contents parsed + // Track the contents parsed + content = *i.Content + if i.Content == nil { + i.Content = fastly.ToPointer("") + } + if i.Main == nil { + b := false + i.Main = &b + } + if i.Name == nil { + i.Name = fastly.ToPointer("") + } + return &fastly.VCL{ + Content: i.Content, + Main: i.Main, + Name: i.Name, + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + }, nil + }, + }, + Args: "--content ./testdata/example.vcl --main --name foo --service-id 123 --version 3", + WantOutput: "Created custom VCL 'foo' (service: 123, version: 3, main: true)", + PathContentFlag: &testutil.PathContentFlag{Flag: "content", Fixture: "example.vcl", Content: func() string { return content }}, + }, + { + Name: "validate --autoclone results in cloned service version", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + CreateVCLFn: func(i *fastly.CreateVCLInput) (*fastly.VCL, error) { + // Track the contents parsed + content = *i.Content + if i.Content == nil { + i.Content = fastly.ToPointer("") + } + if i.Main == nil { + b := false + i.Main = &b + } + if i.Name == nil { + i.Name = fastly.ToPointer("") + } + return &fastly.VCL{ + Content: i.Content, + Main: i.Main, + Name: i.Name, + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + }, nil + }, + }, + Args: "--autoclone --content ./testdata/example.vcl --name foo --service-id 123 --version 1", + WantOutput: "Created custom VCL 'foo' (service: 123, version: 4, main: false)", + PathContentFlag: &testutil.PathContentFlag{Flag: "content", Fixture: "example.vcl", Content: func() string { return content }}, + }, + { + Name: "validate CreateVCL API success with inline VCL content", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + CreateVCLFn: func(i *fastly.CreateVCLInput) (*fastly.VCL, error) { + // Track the contents parsed + content = *i.Content + if i.Content == nil { + i.Content = fastly.ToPointer("") + } + if i.Main == nil { + b := false + i.Main = &b + } + if i.Name == nil { + i.Name = fastly.ToPointer("") + } + return &fastly.VCL{ + Content: i.Content, + Main: i.Main, + Name: i.Name, + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + }, nil + }, + }, + Args: "--content inline_vcl --name foo --service-id 123 --version 3", + WantOutput: "Created custom VCL 'foo' (service: 123, version: 3, main: false)", + PathContentFlag: &testutil.PathContentFlag{Flag: "content", Fixture: "example.vcl", Content: func() string { return content }}, + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "create"}, scenarios) +} + +func TestVCLCustomDelete(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --name flag", + Args: "--version 3", + WantError: "error parsing arguments: required flag --name not provided", + }, + { + Name: "validate missing --version flag", + Args: "--name foobar", + WantError: "error parsing arguments: required flag --version not provided", + }, + { + Name: "validate missing --service-id flag", + Args: "--name foobar --version 3", + WantError: "error reading service: no service ID found", + }, + { + Name: "validate missing --autoclone flag with 'active' service", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + }, + Args: "--name foobar --service-id 123 --version 1", + WantError: "service version 1 is active", + }, + { + Name: "validate missing --autoclone flag with 'locked' service", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + }, + Args: "--name foobar --service-id 123 --version 2", + WantError: "service version 2 is locked", + }, + { + Name: "validate DeleteVCL API error", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + DeleteVCLFn: func(_ *fastly.DeleteVCLInput) error { + return testutil.Err + }, + }, + Args: "--name foobar --service-id 123 --version 3", + WantError: testutil.Err.Error(), + }, + { + Name: "validate DeleteVCL API success", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + DeleteVCLFn: func(_ *fastly.DeleteVCLInput) error { + return nil + }, + }, + Args: "--name foobar --service-id 123 --version 3", + WantOutput: "Deleted custom VCL 'foobar' (service: 123, version: 3)", + }, + { + Name: "validate --autoclone results in cloned service version", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + DeleteVCLFn: func(_ *fastly.DeleteVCLInput) error { + return nil + }, + }, + Args: "--autoclone --name foo --service-id 123 --version 1", + WantOutput: "Deleted custom VCL 'foo' (service: 123, version: 4)", + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "delete"}, scenarios) +} + +func TestVCLCustomDescribe(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --name flag", + Args: "--version 3", + WantError: "error parsing arguments: required flag --name not provided", + }, + { + Name: "validate missing --version flag", + Args: "--name foobar", + WantError: "error parsing arguments: required flag --version not provided", + }, + { + Name: "validate missing --service-id flag", + Args: "--name foobar --version 3", + WantError: "error reading service: no service ID found", + }, + { + Name: "validate GetVCL API error", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + GetVCLFn: func(_ *fastly.GetVCLInput) (*fastly.VCL, error) { + return nil, testutil.Err + }, + }, + Args: "--name foobar --service-id 123 --version 3", + WantError: testutil.Err.Error(), + }, + { + Name: "validate GetVCL API success", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + GetVCLFn: getVCL, + }, + Args: "--name foobar --service-id 123 --version 3", + WantOutput: "\nService ID: 123\nService Version: 3\n\nName: foobar\nMain: true\nContent: \n# some vcl content\n\nCreated at: 2021-06-15 23:00:00 +0000 UTC\nUpdated at: 2021-06-15 23:00:00 +0000 UTC\nDeleted at: 2021-06-15 23:00:00 +0000 UTC\n", + }, + { + Name: "validate missing --autoclone flag is OK", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + GetVCLFn: getVCL, + }, + Args: "--name foobar --service-id 123 --version 1", + WantOutput: "\nService ID: 123\nService Version: 1\n\nName: foobar\nMain: true\nContent: \n# some vcl content\n\nCreated at: 2021-06-15 23:00:00 +0000 UTC\nUpdated at: 2021-06-15 23:00:00 +0000 UTC\nDeleted at: 2021-06-15 23:00:00 +0000 UTC\n", + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "describe"}, scenarios) +} + +func TestVCLCustomList(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --version flag", + WantError: "error parsing arguments: required flag --version not provided", + }, + { + Name: "validate missing --service-id flag", + Args: "--version 3", + WantError: "error reading service: no service ID found", + }, + { + Name: "validate ListVCLs API error", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListVCLsFn: func(_ *fastly.ListVCLsInput) ([]*fastly.VCL, error) { + return nil, testutil.Err + }, + }, + Args: "--service-id 123 --version 3", + WantError: testutil.Err.Error(), + }, + { + Name: "validate ListVCLs API success", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListVCLsFn: listVCLs, + }, + Args: "--service-id 123 --version 3", + WantOutput: "SERVICE ID VERSION NAME MAIN\n123 3 foo true\n123 3 bar false\n", + }, + { + Name: "validate missing --autoclone flag is OK", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListVCLsFn: listVCLs, + }, + Args: "--service-id 123 --version 1", + WantOutput: "SERVICE ID VERSION NAME MAIN\n123 1 foo true\n123 1 bar false\n", + }, + { + Name: "validate missing --verbose flag", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListVCLsFn: listVCLs, + }, + Args: "--service-id 123 --verbose --version 1", + WantOutput: "Fastly API endpoint: https://api.fastly.com\nFastly API token provided via config file (profile: user)\n\nService ID (via --service-id): 123\n\nService Version: 1\n\nName: foo\nMain: true\nContent: \n# some vcl content\n\nCreated at: 2021-06-15 23:00:00 +0000 UTC\nUpdated at: 2021-06-15 23:00:00 +0000 UTC\nDeleted at: 2021-06-15 23:00:00 +0000 UTC\n\nName: bar\nMain: false\nContent: \n# some vcl content\n\nCreated at: 2021-06-15 23:00:00 +0000 UTC\nUpdated at: 2021-06-15 23:00:00 +0000 UTC\nDeleted at: 2021-06-15 23:00:00 +0000 UTC\n", + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "list"}, scenarios) +} + +func TestVCLCustomUpdate(t *testing.T) { + var content string + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --name flag", + Args: "--version 3", + WantError: "error parsing arguments: required flag --name not provided", + }, + { + Name: "validate missing --version flag", + Args: "--name foobar", + WantError: "error parsing arguments: required flag --version not provided", + }, + { + Name: "validate missing --service-id flag", + Args: "--name foobar --version 3", + WantError: "error reading service: no service ID found", + }, + { + Name: "validate missing --autoclone flag with 'active' service", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + }, + Args: "--name foobar --service-id 123 --version 1", + WantError: "service version 1 is active", + }, + { + Name: "validate missing --autoclone flag with 'locked' service", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + }, + Args: "--name foobar --service-id 123 --version 2", + WantError: "service version 2 is locked", + }, + { + Name: "validate UpdateVCL API error", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + UpdateVCLFn: func(_ *fastly.UpdateVCLInput) (*fastly.VCL, error) { + return nil, testutil.Err + }, + }, + Args: "--name foobar --new-name beepboop --service-id 123 --version 3", + WantError: testutil.Err.Error(), + }, + { + Name: "validate UpdateVCL API success with --new-name", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + UpdateVCLFn: func(i *fastly.UpdateVCLInput) (*fastly.VCL, error) { + return &fastly.VCL{ + Content: fastly.ToPointer("# untouched"), + Main: fastly.ToPointer(true), + Name: i.NewName, + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + }, nil + }, + }, + Args: "--name foobar --new-name beepboop --service-id 123 --version 3", + WantOutput: "Updated custom VCL 'beepboop' (previously: 'foobar', service: 123, version: 3)", + }, + { + Name: "validate UpdateVCL API success with --content", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + UpdateVCLFn: func(i *fastly.UpdateVCLInput) (*fastly.VCL, error) { + // Track the contents parsed + content = *i.Content + + return &fastly.VCL{ + Content: i.Content, + Main: fastly.ToPointer(true), + Name: fastly.ToPointer(i.Name), + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + }, nil + }, + }, + Args: "--content updated --name foobar --service-id 123 --version 3", + WantOutput: "Updated custom VCL 'foobar' (service: 123, version: 3)", + PathContentFlag: &testutil.PathContentFlag{Flag: "content", Fixture: "example.vcl", Content: func() string { return content }}, + }, + { + Name: "validate --autoclone results in cloned service version", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + UpdateVCLFn: func(i *fastly.UpdateVCLInput) (*fastly.VCL, error) { + // Track the contents parsed + content = *i.Content + + return &fastly.VCL{ + Content: i.Content, + Main: fastly.ToPointer(true), + Name: fastly.ToPointer(i.Name), + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + }, nil + }, + }, + Args: "--autoclone --content ./testdata/example.vcl --name foo --service-id 123 --version 1", + WantOutput: "Updated custom VCL 'foo' (service: 123, version: 4)", + PathContentFlag: &testutil.PathContentFlag{Flag: "content", Fixture: "example.vcl", Content: func() string { return content }}, + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "update"}, scenarios) +} + +func getVCL(i *fastly.GetVCLInput) (*fastly.VCL, error) { + t := testutil.Date + + return &fastly.VCL{ + Content: fastly.ToPointer("# some vcl content"), + Main: fastly.ToPointer(true), + Name: fastly.ToPointer(i.Name), + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + + CreatedAt: &t, + DeletedAt: &t, + UpdatedAt: &t, + }, nil +} + +func listVCLs(i *fastly.ListVCLsInput) ([]*fastly.VCL, error) { + t := testutil.Date + vs := []*fastly.VCL{ + { + Content: fastly.ToPointer("# some vcl content"), + Main: fastly.ToPointer(true), + Name: fastly.ToPointer("foo"), + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + + CreatedAt: &t, + DeletedAt: &t, + UpdatedAt: &t, + }, + { + Content: fastly.ToPointer("# some vcl content"), + Main: fastly.ToPointer(false), + Name: fastly.ToPointer("bar"), + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + + CreatedAt: &t, + DeletedAt: &t, + UpdatedAt: &t, + }, + } + return vs, nil +} diff --git a/pkg/commands/vcl/custom/delete.go b/pkg/commands/vcl/custom/delete.go new file mode 100644 index 000000000..e03782fa8 --- /dev/null +++ b/pkg/commands/vcl/custom/delete.go @@ -0,0 +1,110 @@ +package custom + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// NewDeleteCommand returns a usable command registered under the parent. +func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { + c := DeleteCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("delete", "Delete the uploaded VCL for a particular service and version").Alias("remove") + + // Required. + c.CmdClause.Flag("name", "The name of the VCL to delete").Required().StringVar(&c.name) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.autoClone.Set, + Dst: &c.autoClone.Value, + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + + return &c +} + +// DeleteCommand calls the Fastly API to delete an appropriate resource. +type DeleteCommand struct { + argparser.Base + + autoClone argparser.OptionalAutoClone + name string + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion +} + +// Exec invokes the application logic for the command. +func (c *DeleteCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.autoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + input := c.constructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + + err = c.Globals.APIClient.DeleteVCL(input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fastly.ToValue(serviceVersion.Number), + }) + return err + } + + text.Success(out, "Deleted custom VCL '%s' (service: %s, version: %d)", c.name, serviceID, fastly.ToValue(serviceVersion.Number)) + return nil +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *DeleteCommand) constructInput(serviceID string, serviceVersion int) *fastly.DeleteVCLInput { + var input fastly.DeleteVCLInput + + input.Name = c.name + input.ServiceID = serviceID + input.ServiceVersion = serviceVersion + + return &input +} diff --git a/pkg/commands/vcl/custom/describe.go b/pkg/commands/vcl/custom/describe.go new file mode 100644 index 000000000..dafa976c9 --- /dev/null +++ b/pkg/commands/vcl/custom/describe.go @@ -0,0 +1,130 @@ +package custom + +import ( + "fmt" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" +) + +// NewDescribeCommand returns a usable command registered under the parent. +func NewDescribeCommand(parent argparser.Registerer, g *global.Data) *DescribeCommand { + c := DescribeCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("describe", "Get the uploaded VCL for a particular service and version").Alias("get") + + // Required. + c.CmdClause.Flag("name", "The name of the VCL").Required().StringVar(&c.name) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + + return &c +} + +// DescribeCommand calls the Fastly API to describe an appropriate resource. +type DescribeCommand struct { + argparser.Base + argparser.JSONOutput + + name string + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion +} + +// Exec invokes the application logic for the command. +func (c *DescribeCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fsterr.ServiceVersion(serviceVersion), + }) + return err + } + + input := c.constructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + + o, err := c.Globals.APIClient.GetVCL(input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fastly.ToValue(serviceVersion.Number), + }) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + return c.print(out, o) +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *DescribeCommand) constructInput(serviceID string, serviceVersion int) *fastly.GetVCLInput { + var input fastly.GetVCLInput + + input.Name = c.name + input.ServiceID = serviceID + input.ServiceVersion = serviceVersion + + return &input +} + +// print displays the information returned from the API. +func (c *DescribeCommand) print(out io.Writer, v *fastly.VCL) error { + if !c.Globals.Verbose() { + fmt.Fprintf(out, "\nService ID: %s\n", fastly.ToValue(v.ServiceID)) + } + fmt.Fprintf(out, "Service Version: %d\n\n", fastly.ToValue(v.ServiceVersion)) + fmt.Fprintf(out, "Name: %s\n", fastly.ToValue(v.Name)) + fmt.Fprintf(out, "Main: %t\n", fastly.ToValue(v.Main)) + fmt.Fprintf(out, "Content: \n%s\n\n", fastly.ToValue(v.Content)) + if v.CreatedAt != nil { + fmt.Fprintf(out, "Created at: %s\n", v.CreatedAt) + } + if v.UpdatedAt != nil { + fmt.Fprintf(out, "Updated at: %s\n", v.UpdatedAt) + } + if v.DeletedAt != nil { + fmt.Fprintf(out, "Deleted at: %s\n", v.DeletedAt) + } + return nil +} diff --git a/pkg/commands/vcl/custom/doc.go b/pkg/commands/vcl/custom/doc.go new file mode 100644 index 000000000..ab52372da --- /dev/null +++ b/pkg/commands/vcl/custom/doc.go @@ -0,0 +1,2 @@ +// Package custom contains commands for managing custom VCL. +package custom diff --git a/pkg/commands/vcl/custom/list.go b/pkg/commands/vcl/custom/list.go new file mode 100644 index 000000000..d8ac4f8cb --- /dev/null +++ b/pkg/commands/vcl/custom/list.go @@ -0,0 +1,153 @@ +package custom + +import ( + "fmt" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// NewListCommand returns a usable command registered under the parent. +func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { + c := ListCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("list", "List the uploaded VCLs for a particular service and version") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + + return &c +} + +// ListCommand calls the Fastly API to list appropriate resources. +type ListCommand struct { + argparser.Base + argparser.JSONOutput + + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion +} + +// Exec invokes the application logic for the command. +func (c *ListCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fsterr.ServiceVersion(serviceVersion), + }) + return err + } + + input := c.constructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + + o, err := c.Globals.APIClient.ListVCLs(input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fastly.ToValue(serviceVersion.Number), + }) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + if c.Globals.Verbose() { + c.printVerbose(out, fastly.ToValue(serviceVersion.Number), o) + } else { + err = c.printSummary(out, o) + if err != nil { + return err + } + } + return nil +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *ListCommand) constructInput(serviceID string, serviceVersion int) *fastly.ListVCLsInput { + var input fastly.ListVCLsInput + + input.ServiceID = serviceID + input.ServiceVersion = serviceVersion + + return &input +} + +// printVerbose displays the information returned from the API in a verbose +// format. +func (c *ListCommand) printVerbose(out io.Writer, serviceVersion int, vs []*fastly.VCL) { + fmt.Fprintf(out, "Service Version: %d\n", serviceVersion) + + for _, v := range vs { + fmt.Fprintf(out, "\nName: %s\n", fastly.ToValue(v.Name)) + fmt.Fprintf(out, "Main: %t\n", fastly.ToValue(v.Main)) + fmt.Fprintf(out, "Content: \n%s\n\n", fastly.ToValue(v.Content)) + if v.CreatedAt != nil { + fmt.Fprintf(out, "Created at: %s\n", v.CreatedAt) + } + if v.UpdatedAt != nil { + fmt.Fprintf(out, "Updated at: %s\n", v.UpdatedAt) + } + if v.DeletedAt != nil { + fmt.Fprintf(out, "Deleted at: %s\n", v.DeletedAt) + } + } +} + +// printSummary displays the information returned from the API in a summarised +// format. +func (c *ListCommand) printSummary(out io.Writer, vs []*fastly.VCL) error { + t := text.NewTable(out) + t.AddHeader("SERVICE ID", "VERSION", "NAME", "MAIN") + for _, v := range vs { + t.AddLine( + fastly.ToValue(v.ServiceID), + fastly.ToValue(v.ServiceVersion), + fastly.ToValue(v.Name), + fastly.ToValue(v.Main), + ) + } + t.Print() + return nil +} diff --git a/pkg/commands/vcl/custom/root.go b/pkg/commands/vcl/custom/root.go new file mode 100644 index 000000000..7fdf937a8 --- /dev/null +++ b/pkg/commands/vcl/custom/root.go @@ -0,0 +1,31 @@ +package custom + +import ( + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" +) + +// RootCommand is the parent command for all subcommands in this package. +// It should be installed under the primary root command. +type RootCommand struct { + argparser.Base + // no flags +} + +// CommandName is the string to be used to invoke this command. +const CommandName = "custom" + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent argparser.Registerer, g *global.Data) *RootCommand { + var c RootCommand + c.Globals = g + c.CmdClause = parent.Command(CommandName, "Manipulate Fastly service version custom VCL") + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { + panic("unreachable") +} diff --git a/pkg/commands/vcl/custom/testdata/example.vcl b/pkg/commands/vcl/custom/testdata/example.vcl new file mode 100644 index 000000000..b99b3c697 --- /dev/null +++ b/pkg/commands/vcl/custom/testdata/example.vcl @@ -0,0 +1 @@ +# some vcl content diff --git a/pkg/commands/vcl/custom/update.go b/pkg/commands/vcl/custom/update.go new file mode 100644 index 000000000..d8d6594ad --- /dev/null +++ b/pkg/commands/vcl/custom/update.go @@ -0,0 +1,147 @@ +package custom + +import ( + "fmt" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// NewUpdateCommand returns a usable command registered under the parent. +func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { + c := UpdateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("update", "Update the uploaded VCL for a particular service and version") + + // Required. + c.CmdClause.Flag("name", "The name of the VCL to update").Required().StringVar(&c.name) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.autoClone.Set, + Dst: &c.autoClone.Value, + }) + c.CmdClause.Flag("new-name", "New name for the VCL").Action(c.newName.Set).StringVar(&c.newName.Value) + c.CmdClause.Flag("content", "VCL passed as file path or content, e.g. $(< main.vcl)").Action(c.content.Set).StringVar(&c.content.Value) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + + return &c +} + +// UpdateCommand calls the Fastly API to update an appropriate resource. +type UpdateCommand struct { + argparser.Base + + autoClone argparser.OptionalAutoClone + content argparser.OptionalString + name string + newName argparser.OptionalString + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion +} + +// Exec invokes the application logic for the command. +func (c *UpdateCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.autoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fsterr.ServiceVersion(serviceVersion), + }) + return err + } + + input, err := c.constructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fastly.ToValue(serviceVersion.Number), + }) + return err + } + + v, err := c.Globals.APIClient.UpdateVCL(input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fastly.ToValue(serviceVersion.Number), + }) + return err + } + + if input.NewName != nil && *input.NewName != "" { + text.Success(out, + "Updated custom VCL '%s' (previously: '%s', service: %s, version: %d)", + fastly.ToValue(v.Name), + input.Name, + fastly.ToValue(v.ServiceID), + fastly.ToValue(v.ServiceVersion), + ) + } else { + text.Success(out, + "Updated custom VCL '%s' (service: %s, version: %d)", + fastly.ToValue(v.Name), + fastly.ToValue(v.ServiceID), + fastly.ToValue(v.ServiceVersion), + ) + } + return nil +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *UpdateCommand) constructInput(serviceID string, serviceVersion int) (*fastly.UpdateVCLInput, error) { + var input fastly.UpdateVCLInput + + input.Name = c.name + input.ServiceID = serviceID + input.ServiceVersion = serviceVersion + + if !c.newName.WasSet && !c.content.WasSet { + return nil, fmt.Errorf("error parsing arguments: must provide either --new-name or --content to update the VCL") + } + if c.newName.WasSet { + input.NewName = &c.newName.Value + } + if c.content.WasSet { + input.Content = fastly.ToPointer(argparser.Content(c.content.Value)) + } + + return &input, nil +} diff --git a/pkg/commands/vcl/doc.go b/pkg/commands/vcl/doc.go new file mode 100644 index 000000000..443825722 --- /dev/null +++ b/pkg/commands/vcl/doc.go @@ -0,0 +1,2 @@ +// Package vcl contains commands for managing VCL. +package vcl diff --git a/pkg/commands/vcl/root.go b/pkg/commands/vcl/root.go new file mode 100644 index 000000000..5dfa3e693 --- /dev/null +++ b/pkg/commands/vcl/root.go @@ -0,0 +1,31 @@ +package vcl + +import ( + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" +) + +// RootCommand is the parent command for all subcommands in this package. +// It should be installed under the primary root command. +type RootCommand struct { + argparser.Base + // no flags +} + +// CommandName is the string to be used to invoke this command. +const CommandName = "vcl" + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent argparser.Registerer, g *global.Data) *RootCommand { + var c RootCommand + c.Globals = g + c.CmdClause = parent.Command(CommandName, "Manipulate Fastly service version VCL") + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { + panic("unreachable") +} diff --git a/pkg/commands/vcl/snippet/create.go b/pkg/commands/vcl/snippet/create.go new file mode 100644 index 000000000..eb67c606e --- /dev/null +++ b/pkg/commands/vcl/snippet/create.go @@ -0,0 +1,147 @@ +package snippet + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// Locations is a list of VCL subroutines. +var Locations = []string{"init", "recv", "hash", "hit", "miss", "pass", "fetch", "error", "deliver", "log", "none"} + +// NewCreateCommand returns a usable command registered under the parent. +func NewCreateCommand(parent argparser.Registerer, g *global.Data) *CreateCommand { + c := CreateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("create", "Create a snippet for a particular service and version").Alias("add") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.autoClone.Set, + Dst: &c.autoClone.Value, + }) + c.CmdClause.Flag("content", "VCL snippet passed as file path or content, e.g. $(< snippet.vcl)").Action(c.content.Set).StringVar(&c.content.Value) + c.CmdClause.Flag("dynamic", "Whether the VCL snippet is dynamic or versioned").Action(c.dynamic.Set).BoolVar(&c.dynamic.Value) + c.CmdClause.Flag("name", "The name of the VCL snippet").Action(c.name.Set).StringVar(&c.name.Value) + c.CmdClause.Flag("priority", "Priority determines execution order. Lower numbers execute first").Short('p').Action(c.priority.Set).StringVar(&c.priority.Value) + + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + c.CmdClause.Flag("type", "The location in generated VCL where the snippet should be placed").Action(c.location.Set).HintOptions(Locations...).EnumVar(&c.location.Value, Locations...) + + return &c +} + +// CreateCommand calls the Fastly API to create an appropriate resource. +type CreateCommand struct { + argparser.Base + + autoClone argparser.OptionalAutoClone + content argparser.OptionalString + dynamic argparser.OptionalBool + location argparser.OptionalString + name argparser.OptionalString + priority argparser.OptionalString + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion +} + +// Exec invokes the application logic for the command. +func (c *CreateCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.autoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + input := c.constructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + + v, err := c.Globals.APIClient.CreateSnippet(input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fastly.ToValue(serviceVersion.Number), + }) + return err + } + + text.Success(out, + "Created VCL snippet '%s' (service: %s, version: %d, dynamic: %t, snippet id: %s, type: %s, priority: %s)", + fastly.ToValue(v.Name), + fastly.ToValue(v.ServiceID), + fastly.ToValue(v.ServiceVersion), + c.dynamic.WasSet, + fastly.ToValue(v.SnippetID), + c.location.Value, + fastly.ToValue(v.Priority), + ) + return nil +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *CreateCommand) constructInput(serviceID string, serviceVersion int) *fastly.CreateSnippetInput { + input := fastly.CreateSnippetInput{ + Dynamic: fastly.ToPointer(0), + ServiceID: serviceID, + ServiceVersion: serviceVersion, + } + if c.name.WasSet { + input.Name = &c.name.Value + } + if c.content.WasSet { + input.Content = fastly.ToPointer(argparser.Content(c.content.Value)) + } + if c.location.WasSet { + sType := fastly.SnippetType(c.location.Value) + input.Type = &sType + } + if c.dynamic.WasSet { + input.Dynamic = fastly.ToPointer(1) + } + if c.priority.WasSet { + input.Priority = &c.priority.Value + } + + return &input +} diff --git a/pkg/commands/vcl/snippet/delete.go b/pkg/commands/vcl/snippet/delete.go new file mode 100644 index 000000000..fdf0a82a1 --- /dev/null +++ b/pkg/commands/vcl/snippet/delete.go @@ -0,0 +1,110 @@ +package snippet + +import ( + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// NewDeleteCommand returns a usable command registered under the parent. +func NewDeleteCommand(parent argparser.Registerer, g *global.Data) *DeleteCommand { + c := DeleteCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("delete", "Delete a specific snippet for a particular service and version").Alias("remove") + + // Required. + c.CmdClause.Flag("name", "The name of the VCL snippet to delete").Required().StringVar(&c.name) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.autoClone.Set, + Dst: &c.autoClone.Value, + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + + return &c +} + +// DeleteCommand calls the Fastly API to delete an appropriate resource. +type DeleteCommand struct { + argparser.Base + + autoClone argparser.OptionalAutoClone + name string + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion +} + +// Exec invokes the application logic for the command. +func (c *DeleteCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: optional.Of(false), + Locked: optional.Of(false), + AutoCloneFlag: c.autoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": errors.ServiceVersion(serviceVersion), + }) + return err + } + + input := c.constructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + + err = c.Globals.APIClient.DeleteSnippet(input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fastly.ToValue(serviceVersion.Number), + }) + return err + } + + text.Success(out, "Deleted VCL snippet '%s' (service: %s, version: %d)", c.name, serviceID, fastly.ToValue(serviceVersion.Number)) + return nil +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *DeleteCommand) constructInput(serviceID string, serviceVersion int) *fastly.DeleteSnippetInput { + var input fastly.DeleteSnippetInput + + input.Name = c.name + input.ServiceID = serviceID + input.ServiceVersion = serviceVersion + + return &input +} diff --git a/pkg/commands/vcl/snippet/describe.go b/pkg/commands/vcl/snippet/describe.go new file mode 100644 index 000000000..6a4841cda --- /dev/null +++ b/pkg/commands/vcl/snippet/describe.go @@ -0,0 +1,204 @@ +package snippet + +import ( + "fmt" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" +) + +// NewDescribeCommand returns a usable command registered under the parent. +func NewDescribeCommand(parent argparser.Registerer, g *global.Data) *DescribeCommand { + c := DescribeCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("describe", "Get the uploaded VCL snippet for a particular service and version").Alias("get") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.CmdClause.Flag("dynamic", "Whether the VCL snippet is dynamic or versioned").Action(c.dynamic.Set).BoolVar(&c.dynamic.Value) + c.RegisterFlagBool(c.JSONFlag()) // --json + c.CmdClause.Flag("name", "The name of the VCL snippet").StringVar(&c.name) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + c.CmdClause.Flag("snippet-id", "Alphanumeric string identifying a VCL Snippet").StringVar(&c.snippetID) + + return &c +} + +// DescribeCommand calls the Fastly API to describe an appropriate resource. +type DescribeCommand struct { + argparser.Base + argparser.JSONOutput + + dynamic argparser.OptionalBool + name string + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion + snippetID string +} + +// Exec invokes the application logic for the command. +func (c *DescribeCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fsterr.ServiceVersion(serviceVersion), + }) + return err + } + + serviceVersionNumber := fastly.ToValue(serviceVersion.Number) + + if c.dynamic.WasSet { + input, err := c.constructDynamicInput(serviceID, serviceVersionNumber) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": serviceVersionNumber, + }) + return err + } + + o, err := c.Globals.APIClient.GetDynamicSnippet(input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": serviceVersionNumber, + }) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + return c.printDynamic(out, o) + } + + input, err := c.constructInput(serviceID, serviceVersionNumber) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": serviceVersionNumber, + }) + return err + } + + o, err := c.Globals.APIClient.GetSnippet(input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": serviceVersionNumber, + }) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + return c.print(out, o) +} + +// constructDynamicInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *DescribeCommand) constructDynamicInput(serviceID string, _ int) (*fastly.GetDynamicSnippetInput, error) { + var input fastly.GetDynamicSnippetInput + + input.SnippetID = c.snippetID + input.ServiceID = serviceID + + if c.snippetID == "" { + return nil, fmt.Errorf("error parsing arguments: must provide --snippet-id with a dynamic VCL snippet") + } + + return &input, nil +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *DescribeCommand) constructInput(serviceID string, serviceVersion int) (*fastly.GetSnippetInput, error) { + var input fastly.GetSnippetInput + + input.Name = c.name + input.ServiceID = serviceID + input.ServiceVersion = serviceVersion + + if c.name == "" { + return nil, fmt.Errorf("error parsing arguments: must provide --name with a versioned VCL snippet") + } + + return &input, nil +} + +// print displays the 'dynamic' information returned from the API. +func (c *DescribeCommand) printDynamic(out io.Writer, ds *fastly.DynamicSnippet) error { + fmt.Fprintf(out, "\nService ID: %s\n", fastly.ToValue(ds.ServiceID)) + fmt.Fprintf(out, "ID: %s\n", fastly.ToValue(ds.SnippetID)) + fmt.Fprintf(out, "Content: \n%s\n", fastly.ToValue(ds.Content)) + if ds.CreatedAt != nil { + fmt.Fprintf(out, "Created at: %s\n", ds.CreatedAt) + } + if ds.UpdatedAt != nil { + fmt.Fprintf(out, "Updated at: %s\n", ds.UpdatedAt) + } + return nil +} + +// print displays the information returned from the API. +func (c *DescribeCommand) print(out io.Writer, s *fastly.Snippet) error { + if !c.Globals.Verbose() { + fmt.Fprintf(out, "\nService ID: %s\n", fastly.ToValue(s.ServiceID)) + } + fmt.Fprintf(out, "Service Version: %d\n", fastly.ToValue(s.ServiceVersion)) + fmt.Fprintf(out, "\nName: %s\n", fastly.ToValue(s.Name)) + fmt.Fprintf(out, "ID: %s\n", fastly.ToValue(s.SnippetID)) + fmt.Fprintf(out, "Priority: %s\n", fastly.ToValue(s.Priority)) + fmt.Fprintf(out, "Dynamic: %t\n", argparser.IntToBool(fastly.ToValue(s.Dynamic))) + fmt.Fprintf(out, "Type: %s\n", fastly.ToValue(s.Type)) + fmt.Fprintf(out, "Content: \n%s\n", fastly.ToValue(s.Content)) + if s.CreatedAt != nil { + fmt.Fprintf(out, "Created at: %s\n", s.CreatedAt) + } + if s.UpdatedAt != nil { + fmt.Fprintf(out, "Updated at: %s\n", s.UpdatedAt) + } + if s.DeletedAt != nil { + fmt.Fprintf(out, "Deleted at: %s\n", s.DeletedAt) + } + return nil +} diff --git a/pkg/commands/vcl/snippet/doc.go b/pkg/commands/vcl/snippet/doc.go new file mode 100644 index 000000000..d598a522b --- /dev/null +++ b/pkg/commands/vcl/snippet/doc.go @@ -0,0 +1,3 @@ +// Package snippet contains commands for managing versioned and dynamic VCL +// snippets. +package snippet diff --git a/pkg/commands/vcl/snippet/list.go b/pkg/commands/vcl/snippet/list.go new file mode 100644 index 000000000..35e83a66f --- /dev/null +++ b/pkg/commands/vcl/snippet/list.go @@ -0,0 +1,159 @@ +package snippet + +import ( + "fmt" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// NewListCommand returns a usable command registered under the parent. +func NewListCommand(parent argparser.Registerer, g *global.Data) *ListCommand { + c := ListCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("list", "List the uploaded VCL snippets for a particular service and version") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterFlagBool(c.JSONFlag()) // --json + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + + return &c +} + +// ListCommand calls the Fastly API to list appropriate resources. +type ListCommand struct { + argparser.Base + argparser.JSONOutput + + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion +} + +// Exec invokes the application logic for the command. +func (c *ListCommand) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fsterr.ServiceVersion(serviceVersion), + }) + return err + } + + input := c.constructInput(serviceID, fastly.ToValue(serviceVersion.Number)) + + o, err := c.Globals.APIClient.ListSnippets(input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fastly.ToValue(serviceVersion.Number), + }) + return err + } + + if ok, err := c.WriteJSON(out, o); ok { + return err + } + + if c.Globals.Verbose() { + c.printVerbose(out, fastly.ToValue(serviceVersion.Number), o) + } else { + err = c.printSummary(out, o) + if err != nil { + return err + } + } + return nil +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *ListCommand) constructInput(serviceID string, serviceVersion int) *fastly.ListSnippetsInput { + var input fastly.ListSnippetsInput + + input.ServiceID = serviceID + input.ServiceVersion = serviceVersion + + return &input +} + +// printVerbose displays the information returned from the API in a verbose +// format. +func (c *ListCommand) printVerbose(out io.Writer, serviceVersion int, vs []*fastly.Snippet) { + fmt.Fprintf(out, "Service Version: %d\n", serviceVersion) + + for _, v := range vs { + fmt.Fprintf(out, "\n") + fmt.Fprintf(out, "Name: %s\n", fastly.ToValue(v.Name)) + fmt.Fprintf(out, "ID: %s\n", fastly.ToValue(v.SnippetID)) + fmt.Fprintf(out, "Priority: %s\n", fastly.ToValue(v.Priority)) + fmt.Fprintf(out, "Dynamic: %t\n", argparser.IntToBool(fastly.ToValue(v.Dynamic))) + fmt.Fprintf(out, "Type: %s\n", fastly.ToValue(v.Type)) + fmt.Fprintf(out, "Content: \n%s\n", fastly.ToValue(v.Content)) + + if v.CreatedAt != nil { + fmt.Fprintf(out, "Created at: %s\n", v.CreatedAt) + } + if v.UpdatedAt != nil { + fmt.Fprintf(out, "Updated at: %s\n", v.UpdatedAt) + } + if v.DeletedAt != nil { + fmt.Fprintf(out, "Deleted at: %s\n", v.DeletedAt) + } + } +} + +// printSummary displays the information returned from the API in a summarised +// format. +func (c *ListCommand) printSummary(out io.Writer, ss []*fastly.Snippet) error { + t := text.NewTable(out) + t.AddHeader("SERVICE ID", "VERSION", "NAME", "DYNAMIC", "SNIPPET ID") + for _, s := range ss { + t.AddLine( + fastly.ToValue(s.ServiceID), + fastly.ToValue(s.ServiceVersion), + fastly.ToValue(s.Name), + argparser.IntToBool(fastly.ToValue(s.Dynamic)), + fastly.ToValue(s.SnippetID), + ) + } + t.Print() + return nil +} diff --git a/pkg/commands/vcl/snippet/root.go b/pkg/commands/vcl/snippet/root.go new file mode 100644 index 000000000..017662e2b --- /dev/null +++ b/pkg/commands/vcl/snippet/root.go @@ -0,0 +1,31 @@ +package snippet + +import ( + "io" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" +) + +// RootCommand is the parent command for all subcommands in this package. +// It should be installed under the primary root command. +type RootCommand struct { + argparser.Base + // no flags +} + +// CommandName is the string to be used to invoke this command. +const CommandName = "snippet" + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent argparser.Registerer, g *global.Data) *RootCommand { + var c RootCommand + c.Globals = g + c.CmdClause = parent.Command(CommandName, "Manipulate Fastly VCL snippets (blocks of VCL logic inserted into your service's configuration that don't require custom VCL)") + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, _ io.Writer) error { + panic("unreachable") +} diff --git a/pkg/commands/vcl/snippet/snippet_test.go b/pkg/commands/vcl/snippet/snippet_test.go new file mode 100644 index 000000000..a0b2e4c3e --- /dev/null +++ b/pkg/commands/vcl/snippet/snippet_test.go @@ -0,0 +1,626 @@ +package snippet_test + +import ( + "testing" + + "github.com/fastly/go-fastly/v10/fastly" + + root "github.com/fastly/cli/pkg/commands/vcl" + sub "github.com/fastly/cli/pkg/commands/vcl/snippet" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" +) + +func TestVCLSnippetCreate(t *testing.T) { + var content string + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --service-id flag", + Args: "--content /path/to/snippet.vcl --name foo --type recv --version 3", + WantError: "error reading service: no service ID found", + }, + { + Name: "validate missing --autoclone flag with 'active' service", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + }, + Args: "--content ./testdata/snippet.vcl --name foo --type recv --service-id 123 --version 1", + WantError: "service version 1 is active", + }, + { + Name: "validate missing --autoclone flag with 'locked' service", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + }, + Args: "--content ./testdata/snippet.vcl --name foo --type recv --service-id 123 --version 2", + WantError: "service version 2 is locked", + }, + { + Name: "validate CreateSnippet API error", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + CreateSnippetFn: func(_ *fastly.CreateSnippetInput) (*fastly.Snippet, error) { + return nil, testutil.Err + }, + }, + Args: "--content ./testdata/snippet.vcl --name foo --type recv --service-id 123 --version 3", + WantError: testutil.Err.Error(), + }, + { + Name: "validate CreateSnippet API success for versioned Snippet", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + CreateSnippetFn: func(i *fastly.CreateSnippetInput) (*fastly.Snippet, error) { + // Track the contents parsed + content = *i.Content + if i.Content == nil { + i.Content = fastly.ToPointer("") + } + if i.Dynamic == nil { + i.Dynamic = fastly.ToPointer(0) + } + if i.Name == nil { + i.Name = fastly.ToPointer("") + } + if i.Priority == nil { + i.Priority = fastly.ToPointer("100") + } + return &fastly.Snippet{ + Content: i.Content, + Dynamic: i.Dynamic, + Name: i.Name, + Priority: i.Priority, + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + SnippetID: fastly.ToPointer("123"), + }, nil + }, + }, + Args: "--content ./testdata/snippet.vcl --name foo --service-id 123 --type recv --version 3", + WantOutput: "Created VCL snippet 'foo' (service: 123, version: 3, dynamic: false, snippet id: 123, type: recv, priority: 100)", + PathContentFlag: &testutil.PathContentFlag{Flag: "content", Fixture: "snippet.vcl", Content: func() string { return content }}, + }, + { + Name: "validate CreateSnippet API success for dynamic Snippet", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + CreateSnippetFn: func(i *fastly.CreateSnippetInput) (*fastly.Snippet, error) { + // Track the contents parsed + content = *i.Content + if i.Content == nil { + i.Content = fastly.ToPointer("") + } + if i.Dynamic == nil { + i.Dynamic = fastly.ToPointer(0) + } + if i.Name == nil { + i.Name = fastly.ToPointer("") + } + if i.Priority == nil { + i.Priority = fastly.ToPointer("100") + } + return &fastly.Snippet{ + Content: i.Content, + Dynamic: i.Dynamic, + Name: i.Name, + Priority: i.Priority, + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + SnippetID: fastly.ToPointer("123"), + }, nil + }, + }, + Args: "--content ./testdata/snippet.vcl --dynamic --name foo --service-id 123 --type recv --version 3", + WantOutput: "Created VCL snippet 'foo' (service: 123, version: 3, dynamic: true, snippet id: 123, type: recv, priority: 100)", + PathContentFlag: &testutil.PathContentFlag{Flag: "content", Fixture: "snippet.vcl", Content: func() string { return content }}, + }, + { + Name: "validate Priority set", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + CreateSnippetFn: func(i *fastly.CreateSnippetInput) (*fastly.Snippet, error) { + // Track the contents parsed + content = *i.Content + if i.Content == nil { + i.Content = fastly.ToPointer("") + } + if i.Dynamic == nil { + i.Dynamic = fastly.ToPointer(0) + } + if i.Name == nil { + i.Name = fastly.ToPointer("") + } + if i.Priority == nil { + i.Priority = fastly.ToPointer("100") + } + return &fastly.Snippet{ + Content: i.Content, + Dynamic: i.Dynamic, + Name: i.Name, + Priority: i.Priority, + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + SnippetID: fastly.ToPointer("123"), + }, nil + }, + }, + Args: "--content ./testdata/snippet.vcl --name foo --priority 1 --service-id 123 --type recv --version 3", + WantOutput: "Created VCL snippet 'foo' (service: 123, version: 3, dynamic: false, snippet id: 123, type: recv, priority: 1)", + PathContentFlag: &testutil.PathContentFlag{Flag: "content", Fixture: "snippet.vcl", Content: func() string { return content }}, + }, + { + Name: "validate --autoclone results in cloned service version", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + CreateSnippetFn: func(i *fastly.CreateSnippetInput) (*fastly.Snippet, error) { + // Track the contents parsed + content = *i.Content + if i.Content == nil { + i.Content = fastly.ToPointer("") + } + if i.Dynamic == nil { + i.Dynamic = fastly.ToPointer(0) + } + if i.Name == nil { + i.Name = fastly.ToPointer("") + } + if i.Priority == nil { + i.Priority = fastly.ToPointer("100") + } + return &fastly.Snippet{ + Content: i.Content, + Dynamic: i.Dynamic, + Name: i.Name, + Priority: i.Priority, + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + SnippetID: fastly.ToPointer("123"), + }, nil + }, + }, + Args: "--autoclone --content ./testdata/snippet.vcl --name foo --service-id 123 --type recv --version 1", + WantOutput: "Created VCL snippet 'foo' (service: 123, version: 4, dynamic: false, snippet id: 123, type: recv, priority: 100)", + PathContentFlag: &testutil.PathContentFlag{Flag: "content", Fixture: "snippet.vcl", Content: func() string { return content }}, + }, + { + Name: "validate CreateSnippet API success with inline Snippet content", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + CreateSnippetFn: func(i *fastly.CreateSnippetInput) (*fastly.Snippet, error) { + // Track the contents parsed + content = *i.Content + if i.Content == nil { + i.Content = fastly.ToPointer("") + } + if i.Dynamic == nil { + i.Dynamic = fastly.ToPointer(0) + } + if i.Name == nil { + i.Name = fastly.ToPointer("") + } + if i.Priority == nil { + i.Priority = fastly.ToPointer("100") + } + return &fastly.Snippet{ + Content: i.Content, + Dynamic: i.Dynamic, + Name: i.Name, + Priority: i.Priority, + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + SnippetID: fastly.ToPointer("123"), + }, nil + }, + }, + Args: "--content inline_vcl --name foo --service-id 123 --type recv --version 3", + WantOutput: "Created VCL snippet 'foo' (service: 123, version: 3, dynamic: false, snippet id: 123, type: recv, priority: 100)", + PathContentFlag: &testutil.PathContentFlag{Flag: "content", Fixture: "snippet.vcl", Content: func() string { return content }}, + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "create"}, scenarios) +} + +func TestVCLSnippetDelete(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --name flag", + Args: "--version 3", + WantError: "error parsing arguments: required flag --name not provided", + }, + { + Name: "validate missing --version flag", + Args: "--name foobar", + WantError: "error parsing arguments: required flag --version not provided", + }, + { + Name: "validate missing --service-id flag", + Args: "--name foobar --version 3", + WantError: "error reading service: no service ID found", + }, + { + Name: "validate missing --autoclone flag with 'active' service", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + }, + Args: "--name foobar --service-id 123 --version 1", + WantError: "service version 1 is active", + }, + { + Name: "validate missing --autoclone flag with 'locked' service", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + }, + Args: "--name foobar --service-id 123 --version 2", + WantError: "service version 2 is locked", + }, + { + Name: "validate DeleteSnippet API error", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + DeleteSnippetFn: func(_ *fastly.DeleteSnippetInput) error { + return testutil.Err + }, + }, + Args: "--name foobar --service-id 123 --version 3", + WantError: testutil.Err.Error(), + }, + { + Name: "validate DeleteSnippet API success", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + DeleteSnippetFn: func(_ *fastly.DeleteSnippetInput) error { + return nil + }, + }, + Args: "--name foobar --service-id 123 --version 3", + WantOutput: "Deleted VCL snippet 'foobar' (service: 123, version: 3)", + }, + { + Name: "validate --autoclone results in cloned service version", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + DeleteSnippetFn: func(_ *fastly.DeleteSnippetInput) error { + return nil + }, + }, + Args: "--autoclone --name foo --service-id 123 --version 1", + WantOutput: "Deleted VCL snippet 'foo' (service: 123, version: 4)", + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "delete"}, scenarios) +} + +func TestVCLSnippetDescribe(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --version flag", + WantError: "error parsing arguments: required flag --version not provided", + }, + { + Name: "validate missing --service-id flag", + Args: "--version 3", + WantError: "error reading service: no service ID found", + }, + { + Name: "validate missing --name flag with versioned snippet", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + }, + Args: "--service-id 123 --version 3", + WantError: "error parsing arguments: must provide --name with a versioned VCL snippet", + }, + { + Name: "validate missing --snippet-id flag with dynamic snippet", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + }, + Args: "--dynamic --service-id 123 --version 3", + WantError: "error parsing arguments: must provide --snippet-id with a dynamic VCL snippet", + }, + { + Name: "validate GetSnippet API error", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + GetSnippetFn: func(_ *fastly.GetSnippetInput) (*fastly.Snippet, error) { + return nil, testutil.Err + }, + }, + Args: "--name foobar --service-id 123 --version 3", + WantError: testutil.Err.Error(), + }, + { + Name: "validate GetSnippet API success", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + GetSnippetFn: getSnippet, + }, + Args: "--name foobar --service-id 123 --version 3", + WantOutput: "\nService ID: 123\nService Version: 3\n\nName: foobar\nID: 456\nPriority: 0\nDynamic: false\nType: recv\nContent: \n# some vcl content\nCreated at: 2021-06-15 23:00:00 +0000 UTC\nUpdated at: 2021-06-15 23:00:00 +0000 UTC\nDeleted at: 2021-06-15 23:00:00 +0000 UTC\n", + }, + { + Name: "validate missing --autoclone flag is OK", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + GetSnippetFn: getSnippet, + }, + Args: "--name foobar --service-id 123 --version 1", + WantOutput: "\nService ID: 123\nService Version: 1\n\nName: foobar\nID: 456\nPriority: 0\nDynamic: false\nType: recv\nContent: \n# some vcl content\nCreated at: 2021-06-15 23:00:00 +0000 UTC\nUpdated at: 2021-06-15 23:00:00 +0000 UTC\nDeleted at: 2021-06-15 23:00:00 +0000 UTC\n", + }, + { + Name: "validate dynamic GetSnippet API success", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + GetDynamicSnippetFn: getDynamicSnippet, + }, + Args: "--dynamic --service-id 123 --snippet-id 456 --version 3", + WantOutput: "\nService ID: 123\nID: 456\nContent: \n# some vcl content\nCreated at: 2021-06-15 23:00:00 +0000 UTC\nUpdated at: 2021-06-15 23:00:00 +0000 UTC\n", + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "describe"}, scenarios) +} + +func TestVCLSnippetList(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --version flag", + WantError: "error parsing arguments: required flag --version not provided", + }, + { + Name: "validate missing --service-id flag", + Args: "--version 3", + WantError: "error reading service: no service ID found", + }, + { + Name: "validate ListSnippets API error", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListSnippetsFn: func(_ *fastly.ListSnippetsInput) ([]*fastly.Snippet, error) { + return nil, testutil.Err + }, + }, + Args: "--service-id 123 --version 3", + WantError: testutil.Err.Error(), + }, + { + Name: "validate ListSnippets API success", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListSnippetsFn: listSnippets, + }, + Args: "--service-id 123 --version 3", + WantOutput: "SERVICE ID VERSION NAME DYNAMIC SNIPPET ID\n123 3 foo true abc\n123 3 bar false abc\n", + }, + { + Name: "validate missing --autoclone flag is OK", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListSnippetsFn: listSnippets, + }, + Args: "--service-id 123 --version 1", + WantOutput: "SERVICE ID VERSION NAME DYNAMIC SNIPPET ID\n123 1 foo true abc\n123 1 bar false abc\n", + }, + { + Name: "validate missing --verbose flag", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + ListSnippetsFn: listSnippets, + }, + Args: "--service-id 123 --verbose --version 1", + WantOutput: "Fastly API endpoint: https://api.fastly.com\nFastly API token provided via config file (profile: user)\n\nService ID (via --service-id): 123\n\nService Version: 1\n\nName: foo\nID: abc\nPriority: 0\nDynamic: true\nType: recv\nContent: \n# some vcl content\nCreated at: 2021-06-15 23:00:00 +0000 UTC\nUpdated at: 2021-06-15 23:00:00 +0000 UTC\nDeleted at: 2021-06-15 23:00:00 +0000 UTC\n\nName: bar\nID: abc\nPriority: 0\nDynamic: false\nType: recv\nContent: \n# some vcl content\nCreated at: 2021-06-15 23:00:00 +0000 UTC\nUpdated at: 2021-06-15 23:00:00 +0000 UTC\nDeleted at: 2021-06-15 23:00:00 +0000 UTC\n", + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "list"}, scenarios) +} + +func TestVCLSnippetUpdate(t *testing.T) { + var content string + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --version flag", + WantError: "error parsing arguments: required flag --version not provided", + }, + { + Name: "validate missing --service-id flag", + Args: "--version 3", + WantError: "error reading service: no service ID found", + }, + { + Name: "validate missing --autoclone flag with 'active' service", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + }, + Args: "--service-id 123 --version 1", + WantError: "service version 1 is active", + }, + { + Name: "validate missing --autoclone flag with 'locked' service", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + }, + Args: "--service-id 123 --version 2", + WantError: "service version 2 is locked", + }, + { + Name: "validate versioned snippet missing --name", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + }, + Args: "--content inline_vcl --new-name bar --service-id 123 --type recv --version 3", + WantError: "error parsing arguments: must provide --name to update a versioned VCL snippet", + }, + { + Name: "validate dynamic snippet missing --snippet-id", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + }, + Args: "--content inline_vcl --dynamic --service-id 123 --version 3", + WantError: "error parsing arguments: must provide --snippet-id to update a dynamic VCL snippet", + }, + { + Name: "validate versioned snippet with --snippet-id is not allowed", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + }, + Args: "--content inline_vcl --new-name foobar --service-id 123 --snippet-id 456 --version 3", + WantError: "error parsing arguments: --snippet-id is not supported when updating a versioned VCL snippet", + }, + { + Name: "validate dynamic snippet with --new-name is not allowed", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + }, + Args: "--content inline_vcl --dynamic --new-name foobar --service-id 123 --snippet-id 456 --version 3", + WantError: "error parsing arguments: --new-name is not supported when updating a dynamic VCL snippet", + }, + { + Name: "validate UpdateSnippet API error", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + UpdateSnippetFn: func(_ *fastly.UpdateSnippetInput) (*fastly.Snippet, error) { + return nil, testutil.Err + }, + }, + Args: "--content inline_vcl --name foo --new-name bar --service-id 123 --type recv --version 3", + WantError: testutil.Err.Error(), + }, + { + Name: "validate UpdateSnippet API success", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + UpdateSnippetFn: func(i *fastly.UpdateSnippetInput) (*fastly.Snippet, error) { + // Track the contents parsed + content = *i.Content + + return &fastly.Snippet{ + Content: i.Content, + Name: i.NewName, + Priority: fastly.ToPointer("100"), + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Type: i.Type, + }, nil + }, + }, + Args: "--content inline_vcl --name foo --new-name bar --service-id 123 --type recv --version 3", + WantOutput: "Updated VCL snippet 'bar' (previously: 'foo', service: 123, version: 3, type: recv, priority: 100)", + PathContentFlag: &testutil.PathContentFlag{Flag: "content", Fixture: "snippet.vcl", Content: func() string { return content }}, + }, + { + Name: "validate UpdateDynamicSnippet API success", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + UpdateDynamicSnippetFn: func(i *fastly.UpdateDynamicSnippetInput) (*fastly.DynamicSnippet, error) { + // Track the contents parsed + content = *i.Content + + return &fastly.DynamicSnippet{ + Content: i.Content, + SnippetID: fastly.ToPointer(i.SnippetID), + ServiceID: fastly.ToPointer(i.ServiceID), + }, nil + }, + }, + Args: "--content inline_vcl --dynamic --service-id 123 --snippet-id 456 --version 3", + WantOutput: "Updated dynamic VCL snippet '456' (service: 123)", + PathContentFlag: &testutil.PathContentFlag{Flag: "content", Fixture: "snippet.vcl", Content: func() string { return content }}, + }, + { + Name: "validate --autoclone results in cloned service version", + API: mock.API{ + ListVersionsFn: testutil.ListVersions, + CloneVersionFn: testutil.CloneVersionResult(4), + UpdateSnippetFn: func(i *fastly.UpdateSnippetInput) (*fastly.Snippet, error) { + // Track the contents parsed + content = *i.Content + + return &fastly.Snippet{ + Content: i.Content, + Name: i.NewName, + Priority: i.Priority, + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Type: i.Type, + }, nil + }, + }, + Args: "--autoclone --content inline_vcl --name foo --new-name bar --priority 1 --service-id 123 --type recv --version 1", + WantOutput: "Updated VCL snippet 'bar' (previously: 'foo', service: 123, version: 4, type: recv, priority: 1)", + PathContentFlag: &testutil.PathContentFlag{Flag: "content", Fixture: "snippet.vcl", Content: func() string { return content }}, + }, + } + + testutil.RunCLIScenarios(t, []string{root.CommandName, sub.CommandName, "update"}, scenarios) +} + +func getSnippet(i *fastly.GetSnippetInput) (*fastly.Snippet, error) { + t := testutil.Date + + return &fastly.Snippet{ + Content: fastly.ToPointer("# some vcl content"), + Dynamic: fastly.ToPointer(0), + SnippetID: fastly.ToPointer("456"), + Name: fastly.ToPointer(i.Name), + Priority: fastly.ToPointer("0"), + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Type: fastly.ToPointer(fastly.SnippetTypeRecv), + + CreatedAt: &t, + DeletedAt: &t, + UpdatedAt: &t, + }, nil +} + +func getDynamicSnippet(i *fastly.GetDynamicSnippetInput) (*fastly.DynamicSnippet, error) { + t := testutil.Date + + return &fastly.DynamicSnippet{ + Content: fastly.ToPointer("# some vcl content"), + SnippetID: fastly.ToPointer(i.SnippetID), + ServiceID: fastly.ToPointer(i.ServiceID), + + CreatedAt: &t, + UpdatedAt: &t, + }, nil +} + +func listSnippets(i *fastly.ListSnippetsInput) ([]*fastly.Snippet, error) { + t := testutil.Date + vs := []*fastly.Snippet{ + { + Content: fastly.ToPointer("# some vcl content"), + Dynamic: fastly.ToPointer(1), + SnippetID: fastly.ToPointer("abc"), + Name: fastly.ToPointer("foo"), + Priority: fastly.ToPointer("0"), + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Type: fastly.ToPointer(fastly.SnippetTypeRecv), + + CreatedAt: &t, + DeletedAt: &t, + UpdatedAt: &t, + }, + { + Content: fastly.ToPointer("# some vcl content"), + Dynamic: fastly.ToPointer(0), + SnippetID: fastly.ToPointer("abc"), + Name: fastly.ToPointer("bar"), + Priority: fastly.ToPointer("0"), + ServiceID: fastly.ToPointer(i.ServiceID), + ServiceVersion: fastly.ToPointer(i.ServiceVersion), + Type: fastly.ToPointer(fastly.SnippetTypeRecv), + + CreatedAt: &t, + DeletedAt: &t, + UpdatedAt: &t, + }, + } + return vs, nil +} diff --git a/pkg/commands/vcl/snippet/testdata/snippet.vcl b/pkg/commands/vcl/snippet/testdata/snippet.vcl new file mode 100644 index 000000000..b99b3c697 --- /dev/null +++ b/pkg/commands/vcl/snippet/testdata/snippet.vcl @@ -0,0 +1 @@ +# some vcl content diff --git a/pkg/commands/vcl/snippet/update.go b/pkg/commands/vcl/snippet/update.go new file mode 100644 index 000000000..8f3c3dcc9 --- /dev/null +++ b/pkg/commands/vcl/snippet/update.go @@ -0,0 +1,211 @@ +package snippet + +import ( + "fmt" + "io" + + "github.com/fastly/go-fastly/v10/fastly" + + "4d63.com/optional" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// NewUpdateCommand returns a usable command registered under the parent. +func NewUpdateCommand(parent argparser.Registerer, g *global.Data) *UpdateCommand { + c := UpdateCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command("update", "Update a VCL snippet for a particular service and version") + + // Required. + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagVersionName, + Description: argparser.FlagVersionDesc, + Dst: &c.serviceVersion.Value, + Required: true, + }) + + // Optional. + c.RegisterAutoCloneFlag(argparser.AutoCloneFlagOpts{ + Action: c.autoClone.Set, + Dst: &c.autoClone.Value, + }) + c.CmdClause.Flag("content", "VCL snippet passed as file path or content, e.g. $(< snippet.vcl)").Action(c.content.Set).StringVar(&c.content.Value) + c.CmdClause.Flag("dynamic", "Whether the VCL snippet is dynamic or versioned").Action(c.dynamic.Set).BoolVar(&c.dynamic.Value) + c.CmdClause.Flag("name", "The name of the VCL snippet to update").StringVar(&c.name) + c.CmdClause.Flag("new-name", "New name for the VCL snippet").Action(c.newName.Set).StringVar(&c.newName.Value) + c.CmdClause.Flag("priority", "Priority determines execution order. Lower numbers execute first").Short('p').Action(c.priority.Set).StringVar(&c.priority.Value) + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + c.CmdClause.Flag("snippet-id", "Alphanumeric string identifying a VCL Snippet").StringVar(&c.snippetID) + + // NOTE: Locations is defined in the same snippet package inside create.go + c.CmdClause.Flag("type", "The location in generated VCL where the snippet should be placed").HintOptions(Locations...).Action(c.location.Set).EnumVar(&c.location.Value, Locations...) + + return &c +} + +// UpdateCommand calls the Fastly API to update an appropriate resource. +type UpdateCommand struct { + argparser.Base + + autoClone argparser.OptionalAutoClone + content argparser.OptionalString + dynamic argparser.OptionalBool + location argparser.OptionalString + name string + newName argparser.OptionalString + priority argparser.OptionalString + serviceName argparser.OptionalServiceNameID + serviceVersion argparser.OptionalServiceVersion + snippetID string +} + +// Exec invokes the application logic for the command. +func (c *UpdateCommand) Exec(_ io.Reader, out io.Writer) error { + // in the normal case, we do not want to allow 'active' or 'locked' services to be updated, + // so we require those states to be 'false' + allowActive := optional.Of(false) + allowLocked := optional.Of(false) + if c.dynamic.WasSet && c.dynamic.Value { + // in this case, we will accept all states ('active' and 'inactive', 'locked' and 'unlocked'), + // so we mark the Optional[bool] fields as 'empty' and they will not be applied as filters + allowActive = optional.Empty[bool]() + allowLocked = optional.Empty[bool]() + } + serviceID, serviceVersion, err := argparser.ServiceDetails(argparser.ServiceDetailsOpts{ + Active: allowActive, + Locked: allowLocked, + AutoCloneFlag: c.autoClone, + APIClient: c.Globals.APIClient, + Manifest: *c.Globals.Manifest, + Out: out, + ServiceNameFlag: c.serviceName, + ServiceVersionFlag: c.serviceVersion, + VerboseMode: c.Globals.Flags.Verbose, + }) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": fsterr.ServiceVersion(serviceVersion), + }) + return err + } + + serviceVersionNumber := fastly.ToValue(serviceVersion.Number) + + if c.dynamic.WasSet { + input, err := c.constructDynamicInput(serviceID, serviceVersionNumber) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": serviceVersionNumber, + }) + return err + } + v, err := c.Globals.APIClient.UpdateDynamicSnippet(input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": serviceVersionNumber, + }) + return err + } + text.Success(out, "Updated dynamic VCL snippet '%s' (service: %s)", fastly.ToValue(v.SnippetID), fastly.ToValue(v.ServiceID)) + return nil + } + + input, err := c.constructInput(serviceID, serviceVersionNumber) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": serviceVersionNumber, + }) + return err + } + v, err := c.Globals.APIClient.UpdateSnippet(input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{ + "Service ID": serviceID, + "Service Version": serviceVersionNumber, + }) + return err + } + text.Success(out, + "Updated VCL snippet '%s' (previously: '%s', service: %s, version: %d, type: %v, priority: %s)", + fastly.ToValue(v.Name), + input.Name, + fastly.ToValue(v.ServiceID), + fastly.ToValue(v.ServiceVersion), + fastly.ToValue(v.Type), + fastly.ToValue(v.Priority), + ) + return nil +} + +// constructDynamicInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *UpdateCommand) constructDynamicInput(serviceID string, _ int) (*fastly.UpdateDynamicSnippetInput, error) { + var input fastly.UpdateDynamicSnippetInput + + input.SnippetID = c.snippetID + input.ServiceID = serviceID + + if c.newName.WasSet { + return nil, fmt.Errorf("error parsing arguments: --new-name is not supported when updating a dynamic VCL snippet") + } + if c.snippetID == "" { + return nil, fmt.Errorf("error parsing arguments: must provide --snippet-id to update a dynamic VCL snippet") + } + if c.content.WasSet { + input.Content = fastly.ToPointer(argparser.Content(c.content.Value)) + } + + return &input, nil +} + +// constructInput transforms values parsed from CLI flags into an object to be used by the API client library. +func (c *UpdateCommand) constructInput(serviceID string, serviceVersion int) (*fastly.UpdateSnippetInput, error) { + var input fastly.UpdateSnippetInput + + input.Name = c.name + input.ServiceID = serviceID + input.ServiceVersion = serviceVersion + + if c.snippetID != "" { + return nil, fmt.Errorf("error parsing arguments: --snippet-id is not supported when updating a versioned VCL snippet") + } + if c.name == "" { + return nil, fmt.Errorf("error parsing arguments: must provide --name to update a versioned VCL snippet") + } + if c.newName.WasSet { + input.NewName = &c.newName.Value + } + if c.priority.WasSet { + input.Priority = &c.priority.Value + } + if c.content.WasSet { + input.Content = fastly.ToPointer(argparser.Content(c.content.Value)) + } + if c.location.WasSet { + location := fastly.SnippetType(c.location.Value) + input.Type = &location + } + + return &input, nil +} diff --git a/pkg/commands/version/doc.go b/pkg/commands/version/doc.go new file mode 100644 index 000000000..7b21f1a61 --- /dev/null +++ b/pkg/commands/version/doc.go @@ -0,0 +1,2 @@ +// Package version contains commands to inspect the Fastly CLI version. +package version diff --git a/pkg/commands/version/root.go b/pkg/commands/version/root.go new file mode 100644 index 000000000..cd1059254 --- /dev/null +++ b/pkg/commands/version/root.go @@ -0,0 +1,77 @@ +package version + +import ( + "fmt" + "io" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/github" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/revision" + "github.com/fastly/cli/pkg/useragent" +) + +func init() { + // Override the go-fastly UserAgent value by prepending the CLI version. + // + // Results in a header similar too: + // User-Agent: FastlyCLI/0.1.0, FastlyGo/1.5.0 (1.13.0) + fastly.UserAgent = fmt.Sprintf("%s, %s", useragent.Name, fastly.UserAgent) +} + +// RootCommand is the parent command for all subcommands in this package. +// It should be installed under the primary root command. +type RootCommand struct { + argparser.Base +} + +// CommandName is the string to be used to invoke this command. +const CommandName = "version" + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent argparser.Registerer, g *global.Data) *RootCommand { + c := RootCommand{ + Base: argparser.Base{ + Globals: g, + }, + } + c.CmdClause = parent.Command(CommandName, "Display version information for the Fastly CLI") + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, out io.Writer) error { + fmt.Fprintf(out, "Fastly CLI version %s (%s)\n", revision.AppVersion, revision.GitCommit) + fmt.Fprintf(out, "Built with %s (%s)\n", revision.GoVersion, Now().Format("2006-01-02")) + + viceroy := filepath.Join(github.InstallDir, c.Globals.Versioners.Viceroy.BinaryName()) + // gosec flagged this: + // G204 (CWE-78): Subprocess launched with variable + // Disabling as we lookup the binary in a trusted location. For this to be a + // concern the user would need to have an already compromised system where an + // attacker could swap the actual viceroy executable for something malicious. + /* #nosec */ + // nosemgrep + command := exec.Command(viceroy, "--version") + if stdoutStderr, err := command.CombinedOutput(); err == nil { + fmt.Fprintf(out, "Viceroy version: %s", stdoutStderr) + } + + return nil +} + +// IsPreRelease determines if the given app version is a pre-release. +// +// NOTE: this is indicated by the presence of a hyphen, e.g. `v1.0.0-rc.1`. +func IsPreRelease(version string) bool { + return strings.Contains(version, "-") +} + +// Now is exposed so that we may mock it from our test file. +var Now = time.Now diff --git a/pkg/commands/version/version_test.go b/pkg/commands/version/version_test.go new file mode 100644 index 000000000..ba14e4ad5 --- /dev/null +++ b/pkg/commands/version/version_test.go @@ -0,0 +1,101 @@ +package version_test + +import ( + "bytes" + "fmt" + "io" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/fastly/cli/pkg/app" + "github.com/fastly/cli/pkg/commands/version" + "github.com/fastly/cli/pkg/github" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/testutil" +) + +func TestVersion(t *testing.T) { + if runtime.GOOS != "darwin" && runtime.GOOS != "linux" { + t.Skip("skipping test due to unix specific mock shell script") + } + + // We're going to chdir to an temp environment, + // so save the PWD to return to, afterwards. + pwd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + + // Create test environment + rootdir := testutil.NewEnv(testutil.EnvOpts{ + T: t, + Write: []testutil.FileIO{ + {Src: `#!/bin/bash + echo viceroy 0.0.0`, Dst: "viceroy"}, + }, + }) + defer os.RemoveAll(rootdir) + + // Ensure the viceroy file we created can be executed. + // + // G302 (CWE-276): Expect file permissions to be 0600 or less + // gosec flagged this: + // Disabling as this is for test suite purposes only. + // #nosec + err = os.Chmod(filepath.Join(rootdir, "viceroy"), 0o777) + if err != nil { + t.Fatal(err) + } + + // Override the InstallDir where the viceroy binary is looked up. + orgInstallDir := github.InstallDir + github.InstallDir = rootdir + defer func() { + github.InstallDir = orgInstallDir + }() + + // Before running the test, chdir into the temp environment. + // When we're done, chdir back to our original location. + // This is so we can reliably assert file structure. + if err := os.Chdir(rootdir); err != nil { + t.Fatal(err) + } + defer func() { + _ = os.Chdir(pwd) + }() + + // Mock the time output to be zero value + version.Now = func() (t time.Time) { + return t + } + + var stdout bytes.Buffer + args := testutil.SplitArgs("version") + opts := testutil.MockGlobalData(args, &stdout) + opts.Versioners = global.Versioners{ + Viceroy: github.New(github.Opts{ + Org: "fastly", + Repo: "viceroy", + Binary: "viceroy", + }), + } + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + return opts, nil + } + err = app.Run(args, nil) + + t.Log(stdout.String()) + + var mockTime time.Time + testutil.AssertNoError(t, err) + testutil.AssertString(t, strings.Join([]string{ + "Fastly CLI version v0.0.0-unknown (unknown)", + fmt.Sprintf("Built with go version %s %s/%s (%s)", runtime.Version(), runtime.GOOS, runtime.GOARCH, mockTime.Format("2006-01-02")), + "Viceroy version: viceroy 0.0.0", + "", + }, "\n"), stdout.String()) +} diff --git a/pkg/commands/whoami/doc.go b/pkg/commands/whoami/doc.go new file mode 100644 index 000000000..9adb5eba2 --- /dev/null +++ b/pkg/commands/whoami/doc.go @@ -0,0 +1,2 @@ +// Package whoami contains commands to inspect the currently authenticated user. +package whoami diff --git a/pkg/commands/whoami/root.go b/pkg/commands/whoami/root.go new file mode 100644 index 000000000..f147d0e7f --- /dev/null +++ b/pkg/commands/whoami/root.go @@ -0,0 +1,127 @@ +package whoami + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "sort" + "strconv" + + "github.com/fastly/cli/pkg/api/undocumented" + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/useragent" +) + +// RootCommand is the parent command for all subcommands in this package. +// It should be installed under the primary root command. +type RootCommand struct { + argparser.Base +} + +// CommandName is the string to be used to invoke this command. +const CommandName = "whoami" + +// NewRootCommand returns a new command registered in the parent. +func NewRootCommand(parent argparser.Registerer, g *global.Data) *RootCommand { + var c RootCommand + c.Globals = g + c.CmdClause = parent.Command(CommandName, "Get information about the currently authenticated account") + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(_ io.Reader, out io.Writer) error { + debugMode, _ := strconv.ParseBool(c.Globals.Env.DebugMode) + token, _ := c.Globals.Token() + apiEndpoint, _ := c.Globals.APIEndpoint() + data, err := undocumented.Call(undocumented.CallOptions{ + APIEndpoint: apiEndpoint, + HTTPClient: c.Globals.HTTPClient, + HTTPHeaders: []undocumented.HTTPHeader{ + { + Key: "Accept", + Value: "application/json", + }, + { + Key: "User-Agent", + Value: useragent.Name, + }, + }, + Method: http.MethodGet, + Path: "/verify", + Token: token, + Debug: debugMode, + }) + if err != nil { + c.Globals.ErrLog.Add(err) + return fmt.Errorf("error executing API request: %w", err) + } + + var response VerifyResponse + if err := json.Unmarshal(data, &response); err != nil { + c.Globals.ErrLog.Add(err) + return fmt.Errorf("error decoding API response: %w", err) + } + + if !c.Globals.Verbose() { + fmt.Fprintf(out, "%s <%s>\n", response.User.Name, response.User.Login) + return nil + } + + keys := make([]string, 0, len(response.Services)) + for k := range response.Services { + keys = append(keys, k) + } + sort.Strings(keys) + + fmt.Fprintf(out, "Customer ID: %s\n", response.Customer.ID) + fmt.Fprintf(out, "Customer name: %s\n", response.Customer.Name) + fmt.Fprintf(out, "User ID: %s\n", response.User.ID) + fmt.Fprintf(out, "User name: %s\n", response.User.Name) + fmt.Fprintf(out, "User login: %s\n", response.User.Login) + fmt.Fprintf(out, "Token ID: %s\n", response.Token.ID) + fmt.Fprintf(out, "Token name: %s\n", response.Token.Name) + fmt.Fprintf(out, "Token created at: %s\n", response.Token.CreatedAt) + if response.Token.ExpiresAt != "" { + fmt.Fprintf(out, "Token expires at: %s\n", response.Token.ExpiresAt) + } + fmt.Fprintf(out, "Token scope: %s\n", response.Token.Scope) + fmt.Fprintf(out, "Service count: %d\n", len(response.Services)) + for _, k := range keys { + fmt.Fprintf(out, "\t%s (%s)\n", response.Services[k], k) + } + + return nil +} + +// VerifyResponse models the Fastly API response for the whoami command. +type VerifyResponse struct { + Customer Customer `json:"customer"` + User User `json:"user"` + Services map[string]string `json:"services"` + Token Token `json:"token"` +} + +// Customer is part of the Fastly API response for the whoami command. +type Customer struct { + ID string `json:"id"` + Name string `json:"name"` +} + +// User is part of the Fastly API response for the whoami command. +type User struct { + ID string `json:"id"` + Name string `json:"name"` + Login string `json:"login"` +} + +// Token is part of the Fastly API response for the whoami command. +type Token struct { + ID string `json:"id"` + Name string `json:"name"` + CreatedAt string `json:"created_at"` + ExpiresAt string `json:"expires_at"` + Scope string `json:"scope"` +} diff --git a/pkg/commands/whoami/whoami_test.go b/pkg/commands/whoami/whoami_test.go new file mode 100644 index 000000000..16d0cd4c5 --- /dev/null +++ b/pkg/commands/whoami/whoami_test.go @@ -0,0 +1,128 @@ +package whoami_test + +import ( + "bytes" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/fastly/cli/pkg/api" + "github.com/fastly/cli/pkg/app" + "github.com/fastly/cli/pkg/config" + "github.com/fastly/cli/pkg/env" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/testutil" +) + +func TestWhoami(t *testing.T) { + args := testutil.SplitArgs + for _, testcase := range []struct { + name string + args []string + env config.Environment + client api.HTTPClient + wantError string + wantOutput string + }{ + { + name: "basic response", + args: args("whoami"), + client: testutil.WhoamiVerifyClient(testutil.WhoamiBasicResponse), + wantOutput: basicOutput, + }, + { + name: "basic response verbose", + args: args("whoami -v"), + client: testutil.WhoamiVerifyClient(testutil.WhoamiBasicResponse), + wantOutput: basicOutputVerbose, + }, + { + name: "500 from API", + args: args("whoami"), + client: codeClient{code: http.StatusInternalServerError}, + wantError: "error executing API request: error response", + }, + { + name: "local error", + args: args("whoami"), + client: errorClient{err: errors.New("some network failure")}, + wantError: "error executing API request: some network failure", + }, + { + name: "alternative endpoint from flag", + args: args("whoami --api=https://staging.fastly.com -v"), + client: testutil.WhoamiVerifyClient(testutil.WhoamiBasicResponse), + wantOutput: strings.ReplaceAll(basicOutputVerbose, + "Fastly API endpoint: https://api.fastly.com", + "Fastly API endpoint (via --api): https://staging.fastly.com", + ), + }, + { + name: "alternative endpoint from environment", + args: args("whoami -v"), + env: config.Environment{APIEndpoint: "https://alternative.example.com"}, + client: testutil.WhoamiVerifyClient(testutil.WhoamiBasicResponse), + wantOutput: strings.ReplaceAll(basicOutputVerbose, + "Fastly API endpoint: https://api.fastly.com", + fmt.Sprintf("Fastly API endpoint (via %s): https://alternative.example.com", env.APIEndpoint), + ), + }, + } { + t.Run(testcase.name, func(t *testing.T) { + var stdout bytes.Buffer + opts := testutil.MockGlobalData(testcase.args, &stdout) + opts.Env = testcase.env + opts.HTTPClient = testcase.client + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + return opts, nil + } + err := app.Run(testcase.args, nil) + opts.Config = config.File{} + t.Log(stdout.String()) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertStringContains(t, stdout.String(), testcase.wantOutput) + }) + } +} + +type codeClient struct { + code int +} + +func (c codeClient) Do(*http.Request) (*http.Response, error) { + rec := httptest.NewRecorder() + rec.WriteHeader(c.code) + return rec.Result(), nil +} + +type errorClient struct { + err error +} + +func (c errorClient) Do(*http.Request) (*http.Response, error) { + return nil, c.err +} + +var basicOutput = "Alice Programmer \n" + +var basicOutputVerbose = strings.TrimSpace(` +Fastly API endpoint: https://api.fastly.com +Fastly API token provided via config file (profile: user) + +Customer ID: abc +Customer name: Computer Company +User ID: 123 +User name: Alice Programmer +User login: alice@example.com +Token ID: abcdefg +Token name: Token name +Token created at: 2019-01-01T12:00:00Z +Token scope: global +Service count: 2 + First service (1xxaa) + Second service (2baba) +`) + "\n" diff --git a/pkg/common/command.go b/pkg/common/command.go deleted file mode 100644 index 40941c905..000000000 --- a/pkg/common/command.go +++ /dev/null @@ -1,105 +0,0 @@ -package common - -import ( - "io" - - "github.com/fastly/cli/pkg/api" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/kingpin" -) - -// Command is an interface that abstracts over all of the concrete command -// structs. The Name method lets us select which command should be run, and the -// Exec method invokes whatever business logic the command should do. -type Command interface { - Name() string - Exec(in io.Reader, out io.Writer) error -} - -// SelectCommand chooses the command matching name, if it exists. -func SelectCommand(name string, commands []Command) (Command, bool) { - for _, command := range commands { - if command.Name() == name { - return command, true - } - } - return nil, false -} - -// Registerer abstracts over a kingpin.App and kingpin.CmdClause. We pass it to -// each concrete command struct's constructor as the "parent" into which the -// command should install itself. -type Registerer interface { - Command(name, help string) *kingpin.CmdClause -} - -// Globals are flags and other stuff that's useful to every command. Globals are -// passed to each concrete command's constructor as a pointer, and are populated -// after a call to Parse. A concrete command's Exec method can use any of the -// information in the globals. -type Globals struct { - Token string - Verbose bool - Client api.Interface -} - -// Base is stuff that should be included in every concrete command. -type Base struct { - CmdClause *kingpin.CmdClause - Globals *config.Data -} - -// Name implements the Command interface, and returns the FullCommand from the -// kingpin.Command that's used to select which command to actually run. -func (b Base) Name() string { - return b.CmdClause.FullCommand() -} - -// Optional models an optional type that consumers can use to assert whether the -// inner value has been set and is therefore valid for use. -type Optional struct { - WasSet bool -} - -// Set implements kingpin.Action and is used as callback to set that the optional -// inner value is valid. -func (o *Optional) Set(e *kingpin.ParseElement, c *kingpin.ParseContext) error { - o.WasSet = true - return nil -} - -// OptionalString models an optional string flag value. -type OptionalString struct { - Optional - Value string -} - -// OptionalStringSlice models an optional string slice flag value. -type OptionalStringSlice struct { - Optional - Value []string -} - -// OptionalBool models an optional boolean flag value. -type OptionalBool struct { - Optional - Value bool -} - -// OptionalUint models an optional uint flag value. -type OptionalUint struct { - Optional - Value uint -} - -// OptionalUint8 models an optional unit8 flag value. -type OptionalUint8 struct { - Optional - Value uint8 -} - -// OptionalInt models an optional int flag value. -type OptionalInt struct { - Optional - Value int -} diff --git a/pkg/common/doc.go b/pkg/common/doc.go deleted file mode 100644 index 3e063acc8..000000000 --- a/pkg/common/doc.go +++ /dev/null @@ -1,3 +0,0 @@ -// Package common contains general-purpose types and helpers. -// Ideally, it should remain as small as possible. -package common diff --git a/pkg/common/exec.go b/pkg/common/exec.go deleted file mode 100644 index b46f0ce21..000000000 --- a/pkg/common/exec.go +++ /dev/null @@ -1,62 +0,0 @@ -package common - -import ( - "bytes" - "fmt" - "io" - "os" - "os/exec" - "strings" - // "sync" -) - -// StreamingExec models a generic command execution that consumers can use to -// execute commands and stream their output to an io.Writer. For example -// compute commands can use this to standardize the flow control for each -// compiler toolchain. -type StreamingExec struct { - command string - args []string - env []string - verbose bool - output io.Writer -} - -// NewStreamingExec constructs a new StreamingExec instance. -func NewStreamingExec(cmd string, args, env []string, verbose bool, out io.Writer) *StreamingExec { - return &StreamingExec{ - cmd, - args, - env, - verbose, - out, - } -} - -// Exec executes the compiler command and pipes the child process stdout and -// stderr output to the supplied io.Writer, it waits for the command to exit -// cleanly or returns an error. -func (s StreamingExec) Exec() error { - // Construct the command with given arguments and environment. - // - // gosec flagged this: - // G204 (CWE-78): Subprocess launched with variable - // Disabling as the variables come from trusted sources. - /* #nosec */ - cmd := exec.Command(s.command, s.args...) - cmd.Env = append(os.Environ(), s.env...) - - // Pipe the child process stdout and stderr to our own output writer. - var stderrBuf bytes.Buffer - cmd.Stdout = s.output - cmd.Stderr = io.MultiWriter(s.output, &stderrBuf) - - if err := cmd.Run(); err != nil { - if !s.verbose && stderrBuf.Len() > 0 { - return fmt.Errorf("error during execution process:\n%s", strings.TrimSpace(stderrBuf.String())) - } - return fmt.Errorf("error during execution process") - } - - return nil -} diff --git a/pkg/common/sync.go b/pkg/common/sync.go deleted file mode 100644 index 67ec5f557..000000000 --- a/pkg/common/sync.go +++ /dev/null @@ -1,26 +0,0 @@ -package common - -import ( - "io" - "sync" -) - -// SyncWriter protects any io.Writer with a mutex. -type SyncWriter struct { - mtx sync.Mutex - w io.Writer -} - -// NewSyncWriter wraps an io.Writer with a mutex. -func NewSyncWriter(w io.Writer) *SyncWriter { - return &SyncWriter{ - w: w, - } -} - -// Write implements io.Writer with mutex protection. -func (w *SyncWriter) Write(p []byte) (int, error) { - w.mtx.Lock() - defer w.mtx.Unlock() - return w.w.Write(p) -} diff --git a/pkg/common/time.go b/pkg/common/time.go deleted file mode 100644 index 349144f33..000000000 --- a/pkg/common/time.go +++ /dev/null @@ -1,5 +0,0 @@ -package common - -// TimeFormat is a format string for time.Format that reflects what the Fastly -// web UI uses. -const TimeFormat = "2006-01-02 15:04" diff --git a/pkg/common/undo.go b/pkg/common/undo.go deleted file mode 100644 index ab67dd532..000000000 --- a/pkg/common/undo.go +++ /dev/null @@ -1,74 +0,0 @@ -package common - -import ( - "fmt" - "io" -) - -// UndoFn is a function with no arguments which returns an error or nil. -type UndoFn func() error - -// UndoStack models a simple undo stack which consumers can use to store undo -// stateful functions, such as a function to teardown API state if something -// goes wrong during procedural commands, for example deleting a Fastly service -// after it's been created. -type UndoStack struct { - states []UndoFn -} - -// Undoer represents the API of an UndoStack. -type Undoer interface { - Pop() UndoFn - Push(elem UndoFn) - Len() int - RunIfError(w io.Writer, err error) -} - -// NewUndoStack constructs a new UndoStack. -func NewUndoStack() *UndoStack { - s := make([]UndoFn, 0, 1) - stack := &UndoStack{ - states: s, - } - return stack -} - -// Pop method pops last added UndoFn element oof the stack and returns it. -// If stack is empty Pop() returns nil. -func (s *UndoStack) Pop() UndoFn { - n := len(s.states) - if n == 0 { - return nil - } - v := s.states[n-1] - s.states = s.states[:n-1] - return v -} - -// Push method pushes an Undoer element onto the UndoStack. -func (s *UndoStack) Push(elem UndoFn) { - s.states = append(s.states, elem) -} - -// Len method returns the number of elements in the UndoStack. -func (s *UndoStack) Len() int { - return len(s.states) -} - -// RunIfError unwinds the stack if a non-nil error is passed, by serially -// calling each UndoFn function state in FIFO order. If any UndoFn returns an -// error, it gets logged to the provided writer. Should be deferrerd, such as: -// -// undoStack := common.NewUndoStack() -// defer func() { undoStack.RunIfError(w, err) }() -// -func (s *UndoStack) RunIfError(w io.Writer, err error) { - if err == nil { - return - } - for i := len(s.states) - 1; i >= 0; i-- { - if err := s.states[i](); err != nil { - fmt.Fprintln(w, err) - } - } -} diff --git a/pkg/compute/assemblyscript.go b/pkg/compute/assemblyscript.go deleted file mode 100644 index 64a0551a5..000000000 --- a/pkg/compute/assemblyscript.go +++ /dev/null @@ -1,199 +0,0 @@ -package compute - -import ( - "fmt" - "io" - "os" - "os/exec" - "path/filepath" - "strings" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/filesystem" - "github.com/fastly/cli/pkg/text" -) - -// AssemblyScript implements Toolchain for the AssemblyScript language. -type AssemblyScript struct{} - -// NewAssemblyScript constructs a new AssemblyScript. -func NewAssemblyScript() *AssemblyScript { - return &AssemblyScript{} -} - -// Verify implements the Toolchain interface and verifies whether the -// AssemblyScript language toolchain is correctly configured on the host. -func (a AssemblyScript) Verify(out io.Writer) error { - // 1) Check `npm` is on $PATH - // - // npm is Node/AssemblyScript's toolchain installer and manager, it is - // needed to assert that the correct versions of the asc compiler and - // @fastly/as-compute package are installed. We only check whether the - // binary exists on the users $PATH and error with installation help text. - fmt.Fprintf(out, "Checking if npm is installed...\n") - - p, err := exec.LookPath("npm") - if err != nil { - return errors.RemediationError{ - Inner: fmt.Errorf("`npm` not found in $PATH"), - Remediation: fmt.Sprintf("To fix this error, install Node.js and npm by visiting:\n\n\t$ %s", text.Bold("https://nodejs.org/")), - } - } - - fmt.Fprintf(out, "Found npm at %s\n", p) - - // 2) Check package.json file exists in $PWD - // - // A valid npm package is needed for compilation and to assert whether the - // required dependencies are installed locally. Therefore, we first assert - // whether one exists in the current $PWD. - fpath, err := filepath.Abs("package.json") - if err != nil { - return fmt.Errorf("getting package.json path: %w", err) - } - - if !filesystem.FileExists(fpath) { - return errors.RemediationError{ - Inner: fmt.Errorf("package.json not found"), - Remediation: fmt.Sprintf("To fix this error, run the following command:\n\n\t$ %s", text.Bold("npm init")), - } - } - - fmt.Fprintf(out, "Found package.json at %s\n", fpath) - - // 3) Check if `asc` is installed. - // - // asc is the AssemblyScript compiler. We first check if it exists in the - // package.json and then whether the binary exists in the npm bin directory. - fmt.Fprintf(out, "Checking if AssemblyScript is installed...\n") - if !checkPackageDependencyExists("assemblyscript") { - return errors.RemediationError{ - Inner: fmt.Errorf("`assemblyscript` not found in package.json"), - Remediation: fmt.Sprintf("To fix this error, run the following command:\n\n\t$ %s", text.Bold("npm install --save-dev assemblyscript")), - } - } - - p, err = getNpmBinPath() - if err != nil { - return errors.RemediationError{ - Inner: fmt.Errorf("could not determine npm bin path"), - Remediation: fmt.Sprintf("To fix this error, run the following command:\n\n\t$ %s", text.Bold("npm install --global npm@latest")), - } - } - - path, err := exec.LookPath(filepath.Join(p, "asc")) - if err != nil { - return fmt.Errorf("getting asc path: %w", err) - } - if !filesystem.FileExists(path) { - return errors.RemediationError{ - Inner: fmt.Errorf("`asc` binary not found in %s", p), - Remediation: fmt.Sprintf("To fix this error, run the following command:\n\n\t$ %s", text.Bold("npm install --save-dev assemblyscript")), - } - } - - fmt.Fprintf(out, "Found asc at %s\n", path) - - return nil -} - -// Initialize implements the Toolchain interface and initializes a newly cloned -// package by installing required dependencies. -func (a AssemblyScript) Initialize(out io.Writer) error { - // 1) Check `npm` is on $PATH - // - // npm is Node/AssemblyScript's toolchain package manager, it is needed to - // install the package dependencies on initialization. We only check whether - // the binary exists on the users $PATH and error with installation help text. - fmt.Fprintf(out, "Checking if npm is installed...\n") - - p, err := exec.LookPath("npm") - if err != nil { - return errors.RemediationError{ - Inner: fmt.Errorf("`npm` not found in $PATH"), - Remediation: fmt.Sprintf("To fix this error, install Node.js and npm by visiting:\n\n\t$ %s", text.Bold("https://nodejs.org/")), - } - } - - fmt.Fprintf(out, "Found npm at %s\n", p) - - // 2) Check package.json file exists in $PWD - // - // A valid npm package manifest file is needed for the install command to - // work. Therefore, we first assert whether one exists in the current $PWD. - fpath, err := filepath.Abs("package.json") - if err != nil { - return fmt.Errorf("getting package.json path: %w", err) - } - - if !filesystem.FileExists(fpath) { - return errors.RemediationError{ - Inner: fmt.Errorf("package.json not found"), - Remediation: fmt.Sprintf("To fix this error, run the following command:\n\n\t$ %s", text.Bold("npm init")), - } - } - - fmt.Fprintf(out, "Found package.json at %s\n", fpath) - - fmt.Fprintf(out, "Installing package dependencies...\n") - - // Call npm install. - cmd := common.NewStreamingExec("npm", []string{"install"}, []string{}, false, out) - return cmd.Exec() -} - -// Build implements the Toolchain interface and attempts to compile the package -// AssemblyScript source to a Wasm binary. -func (a AssemblyScript) Build(out io.Writer, verbose bool) error { - // Check if bin directory exists and create if not. - pwd, err := os.Getwd() - if err != nil { - return fmt.Errorf("getting current working directory: %w", err) - } - binDir := filepath.Join(pwd, "bin") - if err := filesystem.MakeDirectoryIfNotExists(binDir); err != nil { - return fmt.Errorf("making bin directory: %w", err) - } - - npmdir, err := getNpmBinPath() - if err != nil { - return fmt.Errorf("getting npm path: %w", err) - } - - args := []string{ - "assembly/index.ts", - "--binaryFile", - filepath.Join(binDir, "main.wasm"), - "--optimize", - "--noAssert", - } - if verbose { - args = append(args, "--verbose") - } - - // Call asc with the build arguments. - cmd := common.NewStreamingExec(filepath.Join(npmdir, "asc"), args, []string{}, verbose, out) - if err := cmd.Exec(); err != nil { - return err - } - - return nil -} - -func getNpmBinPath() (string, error) { - path, err := exec.Command("npm", "bin").Output() - if err != nil { - return "", err - } - return strings.TrimSpace(string(path)), nil -} - -func checkPackageDependencyExists(name string) bool { - // gosec flagged this: - // G204 (CWE-78): Subprocess launched with variable - // Disabling as the variables come from trusted sources. - /* #nosec */ - err := exec.Command("npm", "list", "--json", "--depth", "0", name).Run() - return err == nil -} diff --git a/pkg/compute/build.go b/pkg/compute/build.go deleted file mode 100644 index f2f1482e4..000000000 --- a/pkg/compute/build.go +++ /dev/null @@ -1,342 +0,0 @@ -package compute - -import ( - "bufio" - "crypto/rand" - "fmt" - "io" - "os" - "path/filepath" - "strings" - - "github.com/fastly/cli/pkg/api" - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/filesystem" - "github.com/fastly/cli/pkg/text" - "github.com/kennygrant/sanitize" - "github.com/mholt/archiver/v3" -) - -// IgnoreFilePath is the filepath name of the Fastly ignore file. -const IgnoreFilePath = ".fastlyignore" - -// Toolchain abstracts a Compute@Edge source language toolchain. -type Toolchain interface { - Initialize(out io.Writer) error - Verify(out io.Writer) error - Build(out io.Writer, verbose bool) error -} - -// Language models a Compute@Edge source language. -type Language struct { - Name string - DisplayName string - StarterKits []config.StarterKit - SourceDirectory string - IncludeFiles []string - - Toolchain -} - -// LanguageOptions models configuration options for a Language. -type LanguageOptions struct { - Name string - DisplayName string - StarterKits []config.StarterKit - SourceDirectory string - IncludeFiles []string - Toolchain Toolchain -} - -// NewLanguage constructs a new Language from a LangaugeOptions. -func NewLanguage(options *LanguageOptions) *Language { - return &Language{ - options.Name, - options.DisplayName, - options.StarterKits, - options.SourceDirectory, - options.IncludeFiles, - options.Toolchain, - } -} - -// BuildCommand produces a deployable artifact from files on the local disk. -type BuildCommand struct { - common.Base - client api.HTTPClient - - // NOTE: these are public so that the "publish" composite command can set the - // values appropriately before calling the Exec() function. - PackageName string - Lang string - IncludeSrc bool - Force bool -} - -// NewBuildCommand returns a usable command registered under the parent. -func NewBuildCommand(parent common.Registerer, client api.HTTPClient, globals *config.Data) *BuildCommand { - var c BuildCommand - c.Globals = globals - c.client = client - c.CmdClause = parent.Command("build", "Build a Compute@Edge package locally") - - // NOTE: when updating these flags, be sure to update the composite command: - // `compute publish`. - c.CmdClause.Flag("name", "Package name").StringVar(&c.PackageName) - c.CmdClause.Flag("language", "Language type").StringVar(&c.Lang) - c.CmdClause.Flag("include-source", "Include source code in built package").BoolVar(&c.IncludeSrc) - c.CmdClause.Flag("force", "Skip verification steps and force build").BoolVar(&c.Force) - - return &c -} - -// Exec implements the command interface. -func (c *BuildCommand) Exec(in io.Reader, out io.Writer) (err error) { - var progress text.Progress - if c.Globals.Verbose() { - progress = text.NewVerboseProgress(out) - } else { - progress = text.NewQuietProgress(out) - } - - defer func() { - if err != nil { - progress.Fail() // progress.Done is handled inline - } - }() - - progress.Step("Verifying package manifest...") - - var m manifest.File - m.SetOutput(c.Globals.Output) - if err := m.Read(ManifestFilename); err != nil { - return fmt.Errorf("error reading package manifest: %w", err) - } - - // Language from flag takes priority, otherwise infer from manifest and - // error if neither are provided. Sanitize by trim and lowercase. - var lang string - if c.Lang != "" { - lang = c.Lang - } else if m.Language != "" { - lang = m.Language - } else { - return fmt.Errorf("language cannot be empty, please provide a language") - } - lang = strings.ToLower(strings.TrimSpace(lang)) - - // Name from flag takes priority, otherwise infer from manifest - // error if neither are provided. Sanitize value to ensure it is a safe - // filepath, replacing spaces with hyphens etc. - var name string - if c.PackageName != "" { - name = c.PackageName - } else if m.Name != "" { - name = m.Name - } else { - return fmt.Errorf("name cannot be empty, please provide a name") - } - name = sanitize.BaseName(name) - - var language *Language - switch lang { - case "assemblyscript": - language = NewLanguage(&LanguageOptions{ - Name: "assemblyscript", - SourceDirectory: "assembly", - IncludeFiles: []string{"package.json"}, - Toolchain: NewAssemblyScript(), - }) - case "rust": - language = NewLanguage(&LanguageOptions{ - Name: "rust", - SourceDirectory: "src", - IncludeFiles: []string{"Cargo.toml"}, - Toolchain: NewRust(c.client, c.Globals), - }) - default: - return fmt.Errorf("unsupported language %s", lang) - } - - if !c.Force { - progress.Step(fmt.Sprintf("Verifying local %s toolchain...", lang)) - - err = language.Verify(progress) - if err != nil { - return err - } - } - - progress.Step(fmt.Sprintf("Building package using %s toolchain...", lang)) - - if err := language.Build(progress, c.Globals.Flag.Verbose); err != nil { - return err - } - - progress.Step("Creating package archive...") - - dest := filepath.Join("pkg", fmt.Sprintf("%s.tar.gz", name)) - - files := []string{ - ManifestFilename, - } - files = append(files, language.IncludeFiles...) - - ignoreFiles, err := getIgnoredFiles(IgnoreFilePath) - if err != nil { - return err - } - - binFiles, err := getNonIgnoredFiles("bin", ignoreFiles) - if err != nil { - return err - } - files = append(files, binFiles...) - - if c.IncludeSrc { - srcFiles, err := getNonIgnoredFiles(language.SourceDirectory, ignoreFiles) - if err != nil { - return err - } - files = append(files, srcFiles...) - } - - err = createPackageArchive(files, dest) - if err != nil { - return fmt.Errorf("error creating package archive: %w", err) - } - - progress.Done() - - text.Success(out, "Built %s package %s (%s)", lang, name, dest) - return nil -} - -// createPackageArchive packages build artifacts as a Fastly package, which -// must be a GZipped Tar archive such as: package-name.tar.gz. -// -// Due to a behavior of archiver.Archive() which recursively writes all files in -// a provided directory to the archive we first copy our input files to a -// temporary directory to ensure only the specified files are included and not -// any in the directory which may be ignored. -func createPackageArchive(files []string, destination string) error { - // Create temporary directory to copy files into. - p := make([]byte, 8) - n, err := rand.Read(p) - if err != nil { - return fmt.Errorf("error creating temporary directory: %w", err) - } - - tmpDir := filepath.Join( - os.TempDir(), - fmt.Sprintf("fastly-build-%x", p[:n]), - ) - - if err := os.MkdirAll(tmpDir, 0700); err != nil { - return fmt.Errorf("error creating temporary directory: %w", err) - } - defer os.RemoveAll(tmpDir) - - // Create implicit top-level directory within temp which will become the - // root of the archive. This replaces the `tar.ImplicitTopLevelFolder` - // behavior. - dir := filepath.Join(tmpDir, fileNameWithoutExtension(destination)) - if err := os.Mkdir(dir, 0700); err != nil { - return fmt.Errorf("error creating temporary directory: %w", err) - } - - for _, src := range files { - dst := filepath.Join(dir, src) - if err = filesystem.CopyFile(src, dst); err != nil { - return fmt.Errorf("error copying file: %w", err) - } - } - - tar := archiver.NewTarGz() - tar.OverwriteExisting = true // - tar.MkdirAll = true // make destination directory if it doesn't exist - - if err = tar.Archive([]string{dir}, destination); err != nil { - return err - } - - return nil -} - -// fileNameWithoutExtension returns a filename with its extension stripped. -func fileNameWithoutExtension(filename string) string { - base := filepath.Base(filename) - firstDot := strings.Index(base, ".") - if firstDot > -1 { - return base[:firstDot] - } - return base -} - -// getIgnoredFiles reads the .fastlyignore file line-by-line and expands the -// glob pattern into a map containing all files it matches. If no ignore file -// is present it returns an empty map. -func getIgnoredFiles(filePath string) (files map[string]bool, err error) { - files = make(map[string]bool) - - if !filesystem.FileExists(filePath) { - return files, nil - } - - // gosec flagged this: - // G304 (CWE-22): Potential file inclusion via variable - // Disabling as we trust the source of the filepath variable as it comes - // from the IgnoreFilePath constant. - /* #nosec */ - file, err := os.Open(filePath) - if err != nil { - return files, err - } - defer func() { - cerr := file.Close() - if err == nil { - err = cerr - } - }() - - scanner := bufio.NewScanner(file) - for scanner.Scan() { - glob := strings.TrimSpace(scanner.Text()) - globFiles, err := filepath.Glob(glob) - if err != nil { - return files, fmt.Errorf("parsing glob %s: %w", glob, err) - } - for _, f := range globFiles { - files[f] = true - } - } - - if err := scanner.Err(); err != nil { - return files, fmt.Errorf("reading %s file: %w", filePath, err) - } - - return files, nil -} - -// getNonIgnoredFiles walks a filepath and returns all files don't exist in the -// provided ignore files map. -func getNonIgnoredFiles(base string, ignoredFiles map[string]bool) ([]string, error) { - var files []string - err := filepath.Walk(base, func(path string, info os.FileInfo, err error) error { - if err != nil { - return err - } - if info.IsDir() { - return nil - } - if ignoredFiles[path] { - return nil - } - files = append(files, path) - return nil - }) - - return files, err -} diff --git a/pkg/compute/compute_integration_test.go b/pkg/compute/compute_integration_test.go deleted file mode 100644 index 1b39ac49b..000000000 --- a/pkg/compute/compute_integration_test.go +++ /dev/null @@ -1,1968 +0,0 @@ -package compute_test - -import ( - "bytes" - "errors" - "fmt" - "io" - "net/http" - "net/http/httptest" - "os" - "os/exec" - "path/filepath" - "strings" - "testing" - - "github.com/fastly/cli/pkg/api" - "github.com/fastly/cli/pkg/app" - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/mock" - "github.com/fastly/cli/pkg/testutil" - "github.com/fastly/cli/pkg/update" - "github.com/fastly/go-fastly/v3/fastly" -) - -func TestInit(t *testing.T) { - if os.Getenv("TEST_COMPUTE_INIT") == "" { - t.Log("skipping test") - t.Skip("Set TEST_COMPUTE_INIT to run this test") - } - - for _, testcase := range []struct { - name string - args []string - configFile config.File - api mock.API - manifest string - wantFiles []string - unwantedFiles []string - stdin string - wantError string - wantOutput []string - manifestIncludes string - }{ - { - name: "unknown repository", - args: []string{"compute", "init", "--from", "https://example.com/template"}, - configFile: config.File{ - User: config.User{ - Token: "123", - }, - }, - api: mock.API{ - GetTokenSelfFn: tokenOK, - GetUserFn: getUserOk, - }, - wantError: "error fetching package template:", - }, - { - name: "with name", - args: []string{"compute", "init", "--name", "test"}, - configFile: config.File{ - User: config.User{ - Token: "123", - }, - StarterKits: config.StarterKitLanguages{ - Rust: []config.StarterKit{ - { - Name: "Default", - Path: "https://github.com/fastly/compute-starter-kit-rust-default.git", - Branch: "0.6.0", - }, - }, - }, - }, - api: mock.API{ - GetTokenSelfFn: tokenOK, - GetUserFn: getUserOk, - }, - wantOutput: []string{ - "Initializing...", - "Fetching package template...", - "Updating package manifest...", - }, - manifestIncludes: `name = "test"`, - }, - { - name: "with description", - args: []string{"compute", "init", "--description", "test"}, - configFile: config.File{ - User: config.User{ - Token: "123", - }, - StarterKits: config.StarterKitLanguages{ - Rust: []config.StarterKit{ - { - Name: "Default", - Path: "https://github.com/fastly/compute-starter-kit-rust-default.git", - Branch: "0.6.0", - }, - }, - }, - }, - api: mock.API{ - GetTokenSelfFn: tokenOK, - GetUserFn: getUserOk, - }, - wantOutput: []string{ - "Initializing...", - "Fetching package template...", - "Updating package manifest...", - }, - manifestIncludes: `description = "test"`, - }, - { - name: "with author", - args: []string{"compute", "init", "--author", "test@example.com"}, - configFile: config.File{ - User: config.User{ - Token: "123", - }, - StarterKits: config.StarterKitLanguages{ - Rust: []config.StarterKit{ - { - Name: "Default", - Path: "https://github.com/fastly/compute-starter-kit-rust-default.git", - Branch: "0.6.0", - }, - }, - }, - }, - api: mock.API{ - GetTokenSelfFn: tokenOK, - GetUserFn: getUserOk, - }, - wantOutput: []string{ - "Initializing...", - "Fetching package template...", - "Updating package manifest...", - }, - manifestIncludes: `authors = ["test@example.com"]`, - }, - { - name: "with multiple authors", - args: []string{"compute", "init", "--author", "test1@example.com", "--author", "test2@example.com"}, - configFile: config.File{ - User: config.User{ - Token: "123", - }, - StarterKits: config.StarterKitLanguages{ - Rust: []config.StarterKit{ - { - Name: "Default", - Path: "https://github.com/fastly/compute-starter-kit-rust-default.git", - Branch: "0.6.0", - }, - }, - }, - }, - api: mock.API{ - GetTokenSelfFn: tokenOK, - GetUserFn: getUserOk, - }, - wantOutput: []string{ - "Initializing...", - "Fetching package template...", - "Updating package manifest...", - }, - manifestIncludes: `authors = ["test1@example.com", "test2@example.com"]`, - }, - { - name: "with from repository and branch", - args: []string{"compute", "init", "--from", "https://github.com/fastly/compute-starter-kit-rust-default.git", "--branch", "main"}, - configFile: config.File{ - User: config.User{ - Token: "123", - }, - StarterKits: config.StarterKitLanguages{ - Rust: []config.StarterKit{ - { - Name: "Default", - Path: "https://github.com/fastly/compute-starter-kit-rust-default.git", - Branch: "0.6.0", - }, - }, - }, - }, - api: mock.API{ - GetTokenSelfFn: tokenOK, - GetUserFn: getUserOk, - }, - wantOutput: []string{ - "Initializing...", - "Fetching package template...", - "Updating package manifest...", - }, - }, - { - name: "with existing package manifest", - args: []string{"compute", "init", "--force"}, // --force will ignore that the directory isn't empty - configFile: config.File{ - User: config.User{ - Token: "123", - }, - StarterKits: config.StarterKitLanguages{ - Rust: []config.StarterKit{ - { - Name: "Default", - Path: "https://github.com/fastly/compute-starter-kit-rust-default.git", - Branch: "0.6.0", - }, - }, - }, - }, - manifest: strings.Join([]string{ - "manifest_version = \"1\"", - "service_id = \"1234\"", - "name = \"test\"", - "language = \"rust\"", - "description = \"test\"", - "authors = [\"test@fastly.com\"]", - }, "\n"), - wantOutput: []string{ - "Updating package manifest...", - "Initializing package...", - }, - }, - { - name: "default", - args: []string{"compute", "init"}, - configFile: config.File{ - User: config.User{ - Token: "123", - Email: "test@example.com", - }, - StarterKits: config.StarterKitLanguages{ - Rust: []config.StarterKit{ - { - Name: "Default", - Path: "https://github.com/fastly/compute-starter-kit-rust-default.git", - Branch: "0.6.0", - }, - }, - }, - }, - api: mock.API{ - GetTokenSelfFn: tokenOK, - GetUserFn: getUserOk, - }, - manifestIncludes: `authors = ["test@example.com"]`, - wantFiles: []string{ - "Cargo.toml", - "fastly.toml", - "src/main.rs", - }, - unwantedFiles: []string{ - "SECURITY.md", - }, - wantOutput: []string{ - "Initializing...", - "Fetching package template...", - "Updating package manifest...", - }, - }, - { - name: "non empty directory", - args: []string{"compute", "init"}, - configFile: config.File{ - User: config.User{ - Token: "123", - Email: "test@example.com", - }, - StarterKits: config.StarterKitLanguages{ - Rust: []config.StarterKit{ - { - Name: "Default", - Path: "https://github.com/fastly/compute-starter-kit-rust-default.git", - Branch: "0.6.0", - }, - }, - }, - }, - api: mock.API{ - GetTokenSelfFn: tokenOK, - GetUserFn: getUserOk, - }, - wantError: "project directory not empty", - manifest: `name = "test"`, // causes a file to be created as part of test setup - }, - { - name: "with default name inferred from directory", - args: []string{"compute", "init"}, - configFile: config.File{ - User: config.User{ - Token: "123", - }, - StarterKits: config.StarterKitLanguages{ - Rust: []config.StarterKit{ - { - Name: "Default", - Path: "https://github.com/fastly/compute-starter-kit-rust-default.git", - Branch: "0.6.0", - }, - }, - }, - }, - api: mock.API{ - GetTokenSelfFn: tokenOK, - GetUserFn: getUserOk, - }, - manifestIncludes: `name = "fastly-init`, - }, - { - name: "with AssemblyScript language", - args: []string{"compute", "init", "--language", "assemblyscript"}, - configFile: config.File{ - User: config.User{ - Token: "123", - }, - StarterKits: config.StarterKitLanguages{ - AssemblyScript: []config.StarterKit{ - { - Name: "Default", - Path: "https://github.com/fastly/compute-starter-kit-assemblyscript-default", - Tag: "v0.2.0", - }, - }, - }, - }, - api: mock.API{ - GetTokenSelfFn: tokenOK, - GetUserFn: getUserOk, - }, - manifestIncludes: `name = "fastly-init`, - }, - } { - t.Run(testcase.name, func(t *testing.T) { - // We're going to chdir to an init environment, - // so save the PWD to return to, afterwards. - pwd, err := os.Getwd() - if err != nil { - t.Fatal(err) - } - - // Create our init environment in a temp dir. - // Defer a call to clean it up. - rootdir := makeInitEnvironment(t, testcase.manifest) - defer os.RemoveAll(rootdir) - - // Before running the test, chdir into the init environment. - // When we're done, chdir back to our original location. - // This is so we can reliably assert file structure. - if err := os.Chdir(rootdir); err != nil { - t.Fatal(err) - } - defer os.Chdir(pwd) - - var ( - args = testcase.args - env = config.Environment{} - file = testcase.configFile - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = bytes.NewBufferString(testcase.stdin) - buf bytes.Buffer - out io.Writer = common.NewSyncWriter(&buf) - ) - err = app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, out) - testutil.AssertErrorContains(t, err, testcase.wantError) - for _, file := range testcase.wantFiles { - if _, err := os.Stat(filepath.Join(rootdir, file)); err != nil { - t.Errorf("wanted file %s not found", file) - } - } - for _, file := range testcase.unwantedFiles { - if _, err := os.Stat(filepath.Join(rootdir, file)); !errors.Is(err, os.ErrNotExist) { - t.Errorf("unwanted file %s found", file) - } - } - for _, s := range testcase.wantOutput { - testutil.AssertStringContains(t, buf.String(), s) - } - if testcase.manifestIncludes != "" { - content, err := os.ReadFile(filepath.Join(rootdir, compute.ManifestFilename)) - if err != nil { - t.Fatal(err) - } - testutil.AssertStringContains(t, string(content), testcase.manifestIncludes) - } - }) - } -} - -// TestBuildRust validates that the rust ecosystem is in place and accurate. -// -// NOTE: -// The defined tests rely on some key pieces of information: -// -// 1. The `fastly` crate internally consumes a `fastly-sys` crate. -// 2. The `fastly-sys` create didn't exist until `fastly` 0.4.0. -// 3. Users of `fastly` should always have the latest `fastly-sys`. -// 4. Users of `fastly` shouldn't need to know about `fastly-sys`. -// 5. Each test has a 'default' Cargo.toml created for it. -// 6. Each test can override the default Cargo.toml by defining a `cargoManifest`. -// -// You can locate the default Cargo.toml here: -// pkg/compute/testdata/build/Cargo.toml -func TestBuildRust(t *testing.T) { - if os.Getenv("TEST_COMPUTE_BUILD_RUST") == "" && os.Getenv("TEST_COMPUTE_BUILD") == "" { - t.Log("skipping test") - t.Skip("Set TEST_COMPUTE_BUILD to run this test") - } - - for _, testcase := range []struct { - name string - args []string - applicationConfig config.File - fastlyManifest string - cargoManifest string - cargoLock string - client api.HTTPClient - wantError string - wantRemediationError string - wantOutputContains string - }{ - { - name: "no fastly.toml manifest", - args: []string{"compute", "build"}, - client: versionClient{fastlyVersions: []string{"0.0.0"}}, - wantError: "error reading package manifest: open fastly.toml:", // actual message differs on Windows - }, - { - name: "empty language", - args: []string{"compute", "build"}, - fastlyManifest: ` - manifest_version = 1 - name = "test"`, - client: versionClient{fastlyVersions: []string{"0.0.0"}}, - wantError: "language cannot be empty, please provide a language", - }, - { - name: "empty name", - args: []string{"compute", "build"}, - fastlyManifest: ` - manifest_version = 1 - language = "rust"`, - client: versionClient{fastlyVersions: []string{"0.0.0"}}, - wantError: "name cannot be empty, please provide a name", - }, - { - name: "unknown language", - args: []string{"compute", "build"}, - fastlyManifest: ` - manifest_version = 1 - name = "test" - language = "foobar"`, - client: versionClient{fastlyVersions: []string{"0.0.0"}}, - wantError: "unsupported language foobar", - }, - { - name: "error reading cargo metadata", - args: []string{"compute", "build"}, - fastlyManifest: ` - manifest_version = 1 - name = "test" - language = "rust"`, - cargoManifest: ` - [package] - name = "test"`, - client: versionClient{fastlyVersions: []string{"0.4.0"}}, - wantError: "reading cargo metadata", - applicationConfig: config.File{ - Language: config.Language{ - Rust: config.Rust{ - // TODO: pull actual version from .github/workflows/pr_test.yml - // when doing local run of integration tests. - ToolchainVersion: "1.49.0", - WasmWasiTarget: "wasm32-wasi", - FastlySysConstraint: "0.0.0", - RustupConstraint: ">= 1.23.0", - }, - }, - }, - }, - { - name: "fastly-sys crate not found", - args: []string{"compute", "build"}, - fastlyManifest: ` - manifest_version = 1 - name = "test" - language = "rust"`, - cargoManifest: ` - [package] - name = "test" - version = "0.1.0" - - [dependencies] - fastly = "=0.3.2"`, - cargoLock: ` - [[package]] - name = "test" - version = "0.1.0" - - [[package]] - name = "fastly" - version = "0.3.2"`, - applicationConfig: config.File{ - Language: config.Language{ - Rust: config.Rust{ - ToolchainVersion: "1.49.0", - WasmWasiTarget: "wasm32-wasi", - FastlySysConstraint: "0.0.0", - RustupConstraint: ">= 1.23.0", - }, - }, - }, - client: versionClient{ - fastlyVersions: []string{"0.4.0"}, - }, - wantError: "fastly-sys crate not found", // fastly 0.3.3 is where fastly-sys was introduced - wantRemediationError: "fastly = \"^0.4.0\"", - }, - { - name: "fastly-sys crate out-of-date", - args: []string{"compute", "build"}, - fastlyManifest: ` - manifest_version = 1 - name = "test" - language = "rust"`, - cargoManifest: ` - [package] - name = "test" - version = "0.1.0" - - [dependencies] - fastly = "=0.4.0"`, - cargoLock: ` - [[package]] - name = "fastly-sys" - version = "0.3.7"`, - applicationConfig: config.File{ - Language: config.Language{ - Rust: config.Rust{ - ToolchainVersion: "1.49.0", - WasmWasiTarget: "wasm32-wasi", - FastlySysConstraint: ">= 0.4.0 <= 0.9.0", // the fastly-sys version in 0.6.0 is actually ^0.3.6 so a minimum of 0.4.0 causes the constraint to fail - RustupConstraint: ">= 1.23.0", - }, - }, - }, - client: versionClient{ - fastlyVersions: []string{"0.6.0"}, - }, - wantError: "fastly crate not up-to-date", - wantRemediationError: "fastly = \"^0.6.0\"", - }, - { - name: "fastly crate prerelease", - args: []string{"compute", "build"}, - fastlyManifest: ` - manifest_version = 1 - name = "test" - language = "rust"`, - applicationConfig: config.File{ - Language: config.Language{ - Rust: config.Rust{ - ToolchainVersion: "1.49.0", - WasmWasiTarget: "wasm32-wasi", - FastlySysConstraint: ">= 0.3.0 <= 0.6.0", - RustupConstraint: ">= 1.23.0", - }, - }, - }, - cargoManifest: ` - [package] - name = "test" - version = "0.1.0" - - [dependencies] - fastly = "0.6.0"`, - cargoLock: ` - [[package]] - name = "fastly-sys" - version = "0.3.7" - - [[package]] - name = "fastly" - version = "0.6.0"`, - client: versionClient{ - fastlyVersions: []string{"0.6.0"}, - }, - wantOutputContains: "Built rust package test", - }, - { - name: "Rust success", - args: []string{"compute", "build"}, - applicationConfig: config.File{ - Language: config.Language{ - Rust: config.Rust{ - ToolchainVersion: "1.49.0", - WasmWasiTarget: "wasm32-wasi", - FastlySysConstraint: ">= 0.3.0 <= 0.6.0", - RustupConstraint: ">= 1.23.0", - }, - }, - }, - fastlyManifest: ` - manifest_version = 1 - name = "test" - language = "rust"`, - cargoManifest: ` - [package] - name = "test" - version = "0.1.0" - - [dependencies] - fastly = "=0.6.0"`, - cargoLock: ` - [[package]] - name = "fastly" - version = "0.6.0" - - [[package]] - name = "fastly-sys" - version = "0.3.7"`, - client: versionClient{ - fastlyVersions: []string{"0.6.0"}, - }, - wantOutputContains: "Built rust package test", - }, - } { - t.Run(testcase.name, func(t *testing.T) { - // We're going to chdir to a build environment, - // so save the PWD to return to, afterwards. - pwd, err := os.Getwd() - if err != nil { - t.Fatal(err) - } - - // Create our build environment in a temp dir. - // Defer a call to clean it up. - rootdir := makeRustBuildEnvironment(t, testcase.fastlyManifest, testcase.cargoManifest, testcase.cargoLock) - defer os.RemoveAll(rootdir) - - // Before running the test, chdir into the build environment. - // When we're done, chdir back to our original location. - // This is so we can reliably copy the testdata/ fixtures. - if err := os.Chdir(rootdir); err != nil { - t.Fatal(err) - } - defer os.Chdir(pwd) - - var ( - args = testcase.args - env = config.Environment{} - file = testcase.applicationConfig - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(mock.API{}) - httpClient = testcase.client - cliVersioner update.Versioner = nil - in io.Reader = nil - buf bytes.Buffer - out io.Writer = common.NewSyncWriter(&buf) - ) - err = app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertRemediationErrorContains(t, err, testcase.wantRemediationError) - if testcase.wantOutputContains != "" { - testutil.AssertStringContains(t, buf.String(), testcase.wantOutputContains) - } - }) - } -} - -func TestBuildAssemblyScript(t *testing.T) { - if os.Getenv("TEST_COMPUTE_BUILD_ASSEMBLYSCRIPT") == "" && os.Getenv("TEST_COMPUTE_BUILD") == "" { - t.Log("skipping test") - t.Skip("Set TEST_COMPUTE_BUILD_ASSEMBLYSCRIPT or TEST_COMPUTE_BUILD to run this test") - } - - for _, testcase := range []struct { - name string - args []string - fastlyManifest string - wantError string - wantRemediationError string - wantOutputContains string - }{ - { - name: "no fastly.toml manifest", - args: []string{"compute", "build"}, - wantError: "error reading package manifest: open fastly.toml:", // actual message differs on Windows - }, - { - name: "empty language", - args: []string{"compute", "build"}, - fastlyManifest: ` - manifest_version = 1 - name = "test"`, - wantError: "language cannot be empty, please provide a language", - }, - { - name: "empty name", - args: []string{"compute", "build"}, - fastlyManifest: ` - manifest_version = 1 - language = "assemblyscript"`, - wantError: "name cannot be empty, please provide a name", - }, - { - name: "unknown language", - args: []string{"compute", "build"}, - fastlyManifest: ` - manifest_version = 1 - name = "test" - language = "javascript"`, - wantError: "unsupported language javascript", - }, - { - name: "AssemblyScript success", - args: []string{"compute", "build"}, - fastlyManifest: ` - manifest_version = 1 - name = "test" - language = "assemblyscript"`, - wantOutputContains: "Built assemblyscript package test", - }, - } { - t.Run(testcase.name, func(t *testing.T) { - // We're going to chdir to a build environment, - // so save the PWD to return to, afterwards. - pwd, err := os.Getwd() - if err != nil { - t.Fatal(err) - } - - // Create our build environment in a temp dir. - // Defer a call to clean it up. - rootdir := makeAssemblyScriptBuildEnvironment(t, testcase.fastlyManifest) - defer os.RemoveAll(rootdir) - - // Before running the test, chdir into the build environment. - // When we're done, chdir back to our original location. - // This is so we can reliably copy the testdata/ fixtures. - if err := os.Chdir(rootdir); err != nil { - t.Fatal(err) - } - defer os.Chdir(pwd) - - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(mock.API{}) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - buf bytes.Buffer - out io.Writer = common.NewSyncWriter(&buf) - ) - err = app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertRemediationErrorContains(t, err, testcase.wantRemediationError) - if testcase.wantOutputContains != "" { - testutil.AssertStringContains(t, buf.String(), testcase.wantOutputContains) - } - }) - } -} - -func TestDeploy(t *testing.T) { - for _, testcase := range []struct { - name string - args []string - manifest string - api mock.API - wantError string - wantOutput []string - manifestIncludes string - in *strings.Reader // to handle text.Input prompts - }{ - { - name: "no token", - args: []string{"compute", "deploy"}, - wantError: "no token provided", - }, - { - name: "no fastly.toml manifest", - args: []string{"compute", "deploy", "--token", "123"}, - in: strings.NewReader(""), - wantError: "error reading package manifest", - wantOutput: []string{ - "Reading package manifest...", - }, - }, - { - // If no Service ID defined via flag or manifest, then the expectation is - // for the service to be created via the API and for the returned ID to - // be stored into the manifest. - // - // Additionally it validates that the specified path (files generated by - // the test suite `makeDeployEnvironment` function) cause no issues. - name: "path with no service ID", - args: []string{"compute", "deploy", "--token", "123", "-v", "-p", "pkg/package.tar.gz"}, - in: strings.NewReader(""), - api: mock.API{ - CreateServiceFn: createServiceOK, - GetPackageFn: getPackageOk, - UpdatePackageFn: updatePackageOk, - CreateDomainFn: createDomainOK, - CreateBackendFn: createBackendOK, - ActivateVersionFn: activateVersionOk, - ListDomainsFn: listDomainsOk, - }, - manifest: "name = \"package\"\n", - wantOutput: []string{ - "Setting service ID in manifest to \"12345\"...", - "Deployed package (service 12345, version 1)", - }, - }, - // Same validation as above with the exception that we use the default path - // parsing logic (i.e. we don't explicitly pass a path via `-p` flag). - { - name: "empty service ID", - args: []string{"compute", "deploy", "--token", "123", "-v"}, - manifest: "name = \"package\"\n", - in: strings.NewReader(""), - api: mock.API{ - CreateServiceFn: createServiceOK, - GetPackageFn: getPackageOk, - UpdatePackageFn: updatePackageOk, - CreateDomainFn: createDomainOK, - CreateBackendFn: createBackendOK, - ActivateVersionFn: activateVersionOk, - ListDomainsFn: listDomainsOk, - }, - wantOutput: []string{ - "Setting service ID in manifest to \"12345\"...", - "Deployed package (service 12345, version 1)", - }, - }, - { - name: "list versions error", - args: []string{"compute", "deploy", "--token", "123"}, - api: mock.API{ - GetServiceFn: getServiceOK, - ListVersionsFn: listVersionsError, - }, - manifest: "name = \"package\"\nservice_id = \"123\"\n", - wantError: "error listing service versions: fixture error", - }, - { - name: "clone version error", - args: []string{"compute", "deploy", "--token", "123"}, - api: mock.API{ - GetServiceFn: getServiceOK, - ListVersionsFn: listVersionsActiveOk, - CloneVersionFn: cloneVersionError, - }, - manifest: "name = \"package\"\nservice_id = \"123\"\n", - wantError: "error cloning latest service version: fixture error", - }, - { - name: "list domains error", - args: []string{"compute", "deploy", "--token", "123"}, - api: mock.API{ - GetServiceFn: getServiceOK, - ListVersionsFn: listVersionsActiveOk, - CloneVersionFn: cloneVersionOk, - ListDomainsFn: listDomainsError, - }, - manifest: "name = \"package\"\nservice_id = \"123\"\n", - wantError: "error fetching service domains: fixture error", - }, - { - name: "list backends error", - args: []string{"compute", "deploy", "--token", "123"}, - api: mock.API{ - GetServiceFn: getServiceOK, - ListVersionsFn: listVersionsActiveOk, - CloneVersionFn: cloneVersionOk, - ListDomainsFn: listDomainsOk, - ListBackendsFn: listBackendsError, - }, - manifest: "name = \"package\"\nservice_id = \"123\"\n", - wantError: "error fetching service backends: fixture error", - }, - { - name: "package API error", - args: []string{"compute", "deploy", "--token", "123"}, - api: mock.API{ - GetServiceFn: getServiceOK, - ListVersionsFn: listVersionsActiveOk, - CloneVersionFn: cloneVersionOk, - ListDomainsFn: listDomainsOk, - ListBackendsFn: listBackendsOk, - GetPackageFn: getPackageOk, - UpdatePackageFn: updatePackageError, - }, - manifest: "name = \"package\"\nservice_id = \"123\"\n", - wantError: "error uploading package: fixture error", - wantOutput: []string{ - "Reading package manifest...", - "Validating package...", - "Uploading package...", - }, - }, - // The following test doesn't provide a Service ID by either a flag nor the - // manifest, so this will result in the deploy script attempting to create - // a new service. We mock the API call to fail, and we expect to see a - // relevant error message related to that error. - { - name: "service create error", - args: []string{"compute", "deploy", "--token", "123"}, - in: strings.NewReader(""), - api: mock.API{ - CreateServiceFn: createServiceError, - }, - manifest: "name = \"package\"\n", - wantError: "error creating service: fixture error", - wantOutput: []string{ - "Reading package manifest...", - "Creating service...", - }, - }, - // The following test doesn't provide a Service ID by either a flag nor the - // manifest, so this will result in the deploy script attempting to create - // a new service. We mock the service creation to be successful while we - // mock the domain API call to fail, and we expect to see a relevant error - // message related to that error. - { - name: "service domain error", - args: []string{"compute", "deploy", "--token", "123"}, - in: strings.NewReader(""), - api: mock.API{ - GetServiceFn: getServiceOK, - CreateServiceFn: createServiceOK, - CreateDomainFn: createDomainError, - DeleteDomainFn: deleteDomainOK, - DeleteServiceFn: deleteServiceOK, - }, - manifest: "name = \"package\"\n", - wantError: "error creating domain: fixture error", - wantOutput: []string{ - "Reading package manifest...", - "Creating service...", - "Creating domain...", - }, - }, - // The following test mocks the backend API call to fail, and we expect to - // see a relevant error message related to that error. - - // The following test doesn't provide a Service ID by either a flag nor the - // manifest, so this will result in the deploy script attempting to create - // a new service. We mock the service creation to be successful while we - // mock the backend API call to fail, and we expect to see a relevant error - // message related to that error. - { - name: "service backend error", - args: []string{"compute", "deploy", "--token", "123"}, - in: strings.NewReader(""), - api: mock.API{ - GetServiceFn: getServiceOK, - CreateServiceFn: createServiceOK, - CloneVersionFn: cloneVersionOk, - CreateDomainFn: createDomainOK, - CreateBackendFn: createBackendError, - DeleteBackendFn: deleteBackendOK, - DeleteDomainFn: deleteDomainOK, - DeleteServiceFn: deleteServiceOK, - }, - manifest: "name = \"package\"\n", - wantError: "error creating backend: fixture error", - wantOutput: []string{ - "Reading package manifest...", - "Creating service...", - "Creating domain...", - "Creating backend...", - }, - }, - // The following test additionally validates that the undoStack is executed - // as expected (e.g. the backend and domain resources are deleted). - { - name: "activate error", - args: []string{"compute", "deploy", "--token", "123"}, - in: strings.NewReader(""), - api: mock.API{ - GetServiceFn: getServiceOK, - ListVersionsFn: listVersionsActiveOk, - CloneVersionFn: cloneVersionOk, - ListDomainsFn: listDomainsOk, - ListBackendsFn: listBackendsOk, - GetPackageFn: getPackageOk, - UpdatePackageFn: updatePackageOk, - ActivateVersionFn: activateVersionError, - }, - manifest: "name = \"package\"\nservice_id = \"123\"\n", - wantError: "error activating version: fixture error", - wantOutput: []string{ - "Reading package manifest...", - "Validating package...", - "Uploading package...", - "Activating version...", - }, - }, - { - name: "indentical package", - args: []string{"compute", "deploy", "--token", "123"}, - api: mock.API{ - GetServiceFn: getServiceOK, - ListVersionsFn: listVersionsActiveOk, - CloneVersionFn: cloneVersionOk, - ListDomainsFn: listDomainsOk, - ListBackendsFn: listBackendsOk, - GetPackageFn: getPackageIdentical, - }, - manifest: "name = \"package\"\nservice_id = \"123\"\n", - wantOutput: []string{ - "Reading package manifest...", - "Validating package...", - "Skipping package deployment", - }, - }, - { - name: "success", - args: []string{"compute", "deploy", "--token", "123"}, - in: strings.NewReader(""), - api: mock.API{ - GetServiceFn: getServiceOK, - ListVersionsFn: listVersionsActiveOk, - CloneVersionFn: cloneVersionOk, - ListDomainsFn: listDomainsOk, - ListBackendsFn: listBackendsOk, - GetPackageFn: getPackageOk, - UpdatePackageFn: updatePackageOk, - ActivateVersionFn: activateVersionOk, - }, - manifest: "name = \"package\"\nservice_id = \"123\"\n", - wantOutput: []string{ - "Reading package manifest...", - "Validating package...", - "Uploading package...", - "Activating version...", - "Manage this service at:", - "https://manage.fastly.com/configure/services/123", - "View this service at:", - "https://directly-careful-coyote.edgecompute.app", - "Deployed package (service 123, version 2)", - }, - }, - { - name: "success with path", - args: []string{"compute", "deploy", "--token", "123", "-p", "pkg/package.tar.gz", "-s", "123"}, - in: strings.NewReader(""), - api: mock.API{ - GetServiceFn: getServiceOK, - ListVersionsFn: listVersionsActiveOk, - CloneVersionFn: cloneVersionOk, - ListDomainsFn: listDomainsOk, - ListBackendsFn: listBackendsOk, - GetPackageFn: getPackageOk, - UpdatePackageFn: updatePackageOk, - ActivateVersionFn: activateVersionOk, - }, - manifest: "name = \"package\"\nservice_id = \"123\"\n", - wantOutput: []string{ - "Uploading package...", - "Activating version...", - "Manage this service at:", - "https://manage.fastly.com/configure/services/123", - "View this service at:", - "https://directly-careful-coyote.edgecompute.app", - "Deployed package (service 123, version 2)", - }, - }, - // The following test validates when the ideal latest version is 'inactive', - // then we don't clone the version as we can just go ahead and activate it. - { - name: "success with inactive version", - args: []string{"compute", "deploy", "--token", "123", "-p", "pkg/package.tar.gz", "-s", "123"}, - in: strings.NewReader(""), - api: mock.API{ - GetServiceFn: getServiceOK, - ListVersionsFn: listVersionsInactiveOk, - ListDomainsFn: listDomainsOk, - ListBackendsFn: listBackendsOk, - GetPackageFn: getPackageOk, - UpdatePackageFn: updatePackageOk, - ActivateVersionFn: activateVersionOk, - }, - manifest: "name = \"package\"\nservice_id = \"123\"\n", - wantOutput: []string{ - "Validating package...", - "Uploading package...", - "Activating version...", - "Deployed package (service 123, version 2)", - }, - }, - { - name: "success with version", - args: []string{"compute", "deploy", "--token", "123", "-p", "pkg/package.tar.gz", "-s", "123", "--version", "2"}, - in: strings.NewReader(""), - api: mock.API{ - GetServiceFn: getServiceOK, - ListDomainsFn: listDomainsOk, - ListBackendsFn: listBackendsOk, - GetPackageFn: getPackageOk, - UpdatePackageFn: updatePackageOk, - ActivateVersionFn: activateVersionOk, - }, - manifest: "name = \"package\"\nservice_id = \"123\"\n", - wantOutput: []string{ - "Validating package...", - "Uploading package...", - "Activating version...", - "Deployed package (service 123, version 2)", - }, - }, - } { - t.Run(testcase.name, func(t *testing.T) { - // We're going to chdir to a deploy environment, - // so save the PWD to return to, afterwards. - pwd, err := os.Getwd() - if err != nil { - t.Fatal(err) - } - - // Create our deploy environment in a temp dir. - // Defer a call to clean it up. - rootdir := makeDeployEnvironment(t, testcase.manifest) - defer os.RemoveAll(rootdir) - - // Before running the test, chdir into the build environment. - // When we're done, chdir back to our original location. - // This is so we can reliably copy the testdata/ fixtures. - if err := os.Chdir(rootdir); err != nil { - t.Fatal(err) - } - defer os.Chdir(pwd) - - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = testcase.in - buf bytes.Buffer - out io.Writer = common.NewSyncWriter(&buf) - ) - - err = app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, out) - - testutil.AssertErrorContains(t, err, testcase.wantError) - - for _, s := range testcase.wantOutput { - testutil.AssertStringContains(t, buf.String(), s) - } - - if testcase.manifestIncludes != "" { - content, err := os.ReadFile(filepath.Join(rootdir, compute.ManifestFilename)) - if err != nil { - t.Fatal(err) - } - testutil.AssertStringContains(t, string(content), testcase.manifestIncludes) - } - }) - } -} - -func TestPublish(t *testing.T) { - for _, testcase := range []struct { - name string - args []string - applicationConfig config.File - fastlyManifest string - cargoManifest string - cargoLock string - client api.HTTPClient - in io.Reader - api mock.API - wantError string - wantOutput []string - manifestIncludes string - }{ - { - name: "success no command flags", - args: []string{"compute", "publish", "-t", "123"}, - applicationConfig: config.File{ - Language: config.Language{ - Rust: config.Rust{ - ToolchainVersion: "1.49.0", - WasmWasiTarget: "wasm32-wasi", - FastlySysConstraint: ">= 0.3.0 <= 0.6.0", - RustupConstraint: ">= 1.23.0", - }, - }, - }, - fastlyManifest: ` - manifest_version = 1 - service_id = "123" - name = "test" - language = "rust"`, - cargoManifest: ` - [package] - name = "test" - version = "0.1.0" - - [dependencies] - fastly = "=0.6.0"`, - cargoLock: ` - [[package]] - name = "fastly" - version = "0.6.0" - - [[package]] - name = "fastly-sys" - version = "0.3.7"`, - client: versionClient{ - fastlyVersions: []string{"0.6.0"}, - }, - in: strings.NewReader(""), - api: mock.API{ - GetServiceFn: getServiceOK, - ListVersionsFn: listVersionsActiveOk, - CloneVersionFn: cloneVersionOk, - ListBackendsFn: listBackendsOk, - ListDomainsFn: listDomainsOk, - GetPackageFn: getPackageOk, - UpdatePackageFn: updatePackageOk, - CreateDomainFn: createDomainOK, - CreateBackendFn: createBackendOK, - ActivateVersionFn: activateVersionOk, - }, - wantOutput: []string{ - "Built rust package test", - "Reading package manifest...", - "Validating package...", - "Uploading package...", - "Activating version...", - "Manage this service at:", - "https://manage.fastly.com/configure/services/123", - "View this service at:", - "https://directly-careful-coyote.edgecompute.app", - "Deployed package (service 123, version 2)", - }, - }, - { - name: "success with build command flags", - args: []string{"compute", "publish", "-t", "123", "--name", "test", "--language", "rust", "--include-source", "--force"}, - applicationConfig: config.File{ - Language: config.Language{ - Rust: config.Rust{ - ToolchainVersion: "1.49.0", - WasmWasiTarget: "wasm32-wasi", - FastlySysConstraint: ">= 0.3.0 <= 0.6.0", - RustupConstraint: ">= 1.23.0", - }, - }, - }, - fastlyManifest: ` - manifest_version = 1 - service_id = "123" - name = "test" - language = "rust"`, - cargoManifest: ` - [package] - name = "test" - version = "0.1.0" - - [dependencies] - fastly = "=0.6.0"`, - cargoLock: ` - [[package]] - name = "fastly" - version = "0.6.0" - - [[package]] - name = "fastly-sys" - version = "0.3.7"`, - client: versionClient{ - fastlyVersions: []string{"0.6.0"}, - }, - in: strings.NewReader(""), - api: mock.API{ - GetServiceFn: getServiceOK, - ListVersionsFn: listVersionsActiveOk, - CloneVersionFn: cloneVersionOk, - ListBackendsFn: listBackendsOk, - ListDomainsFn: listDomainsOk, - GetPackageFn: getPackageOk, - UpdatePackageFn: updatePackageOk, - CreateDomainFn: createDomainOK, - CreateBackendFn: createBackendOK, - ActivateVersionFn: activateVersionOk, - }, - wantOutput: []string{ - "Built rust package test", - "Reading package manifest...", - "Validating package...", - "Uploading package...", - "Activating version...", - "Manage this service at:", - "https://manage.fastly.com/configure/services/123", - "View this service at:", - "https://directly-careful-coyote.edgecompute.app", - "Deployed package (service 123, version 2)", - }, - }, - { - name: "success with deploy command flags", - args: []string{"compute", "publish", "-t", "123", "--version", "2", "--path", "pkg/test.tar.gz"}, - applicationConfig: config.File{ - Language: config.Language{ - Rust: config.Rust{ - ToolchainVersion: "1.49.0", - WasmWasiTarget: "wasm32-wasi", - FastlySysConstraint: ">= 0.3.0 <= 0.6.0", - RustupConstraint: ">= 1.23.0", - }, - }, - }, - fastlyManifest: ` - manifest_version = 1 - service_id = "123" - name = "test" - language = "rust"`, - cargoManifest: ` - [package] - name = "test" - version = "0.1.0" - - [dependencies] - fastly = "=0.6.0"`, - cargoLock: ` - [[package]] - name = "fastly" - version = "0.6.0" - - [[package]] - name = "fastly-sys" - version = "0.3.7"`, - client: versionClient{ - fastlyVersions: []string{"0.6.0"}, - }, - in: strings.NewReader(""), - api: mock.API{ - GetServiceFn: getServiceOK, - ListVersionsFn: listVersionsActiveOk, - CloneVersionFn: cloneVersionOk, - ListDomainsFn: listDomainsOk, - ListBackendsFn: listBackendsOk, - GetPackageFn: getPackageOk, - UpdatePackageFn: updatePackageOk, - CreateDomainFn: createDomainOK, - CreateBackendFn: createBackendOK, - ActivateVersionFn: activateVersionOk, - }, - wantOutput: []string{ - "Built rust package test", - "Validating package...", - "Uploading package...", - "Activating version...", - "Manage this service at:", - "https://manage.fastly.com/configure/services/123", - "View this service at:", - "https://directly-careful-coyote.edgecompute.app", - "Deployed package (service 123, version 2)", - }, - }, - } { - t.Run(testcase.name, func(t *testing.T) { - // We're going to chdir to a deploy environment, - // so save the PWD to return to, afterwards. - pwd, err := os.Getwd() - if err != nil { - t.Fatal(err) - } - - // Create our publish environment in a temp dir. - // Defer a call to clean it up. - rootdir := makePublishEnvironment(t, testcase.fastlyManifest, testcase.cargoManifest, testcase.cargoLock) - defer os.RemoveAll(rootdir) - - // Before running the test, chdir into the build environment. - // When we're done, chdir back to our original location. - // This is so we can reliably copy the testdata/ fixtures. - if err := os.Chdir(rootdir); err != nil { - t.Fatal(err) - } - defer os.Chdir(pwd) - - var ( - args = testcase.args - env = config.Environment{} - file = testcase.applicationConfig - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = testcase.client - cliVersioner update.Versioner = nil - in io.Reader = testcase.in - buf bytes.Buffer - out io.Writer = common.NewSyncWriter(&buf) - ) - err = app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, out) - testutil.AssertErrorContains(t, err, testcase.wantError) - for _, s := range testcase.wantOutput { - testutil.AssertStringContains(t, buf.String(), s) - } - if testcase.manifestIncludes != "" { - content, err := os.ReadFile(filepath.Join(rootdir, compute.ManifestFilename)) - if err != nil { - t.Fatal(err) - } - testutil.AssertStringContains(t, string(content), testcase.manifestIncludes) - } - }) - } -} - -func TestUpdate(t *testing.T) { - for _, testcase := range []struct { - name string - args []string - api mock.API - wantError string - wantOutput []string - }{ - { - name: "package API error", - args: []string{"compute", "update", "-s", "123", "--version", "1", "-p", "pkg/package.tar.gz", "-t", "123"}, - api: mock.API{ - UpdatePackageFn: updatePackageError, - }, - wantError: "error uploading package: fixture error", - wantOutput: []string{ - "Initializing...", - "Uploading package...", - }, - }, - { - name: "success", - args: []string{"compute", "update", "-s", "123", "--version", "1", "-p", "pkg/package.tar.gz", "-t", "123"}, - api: mock.API{ - UpdatePackageFn: updatePackageOk, - }, - wantOutput: []string{ - "Initializing...", - "Uploading package...", - "Updated package (service 123, version 1)", - }, - }, - } { - t.Run(testcase.name, func(t *testing.T) { - // We're going to chdir to a deploy environment, - // so save the PWD to return to, afterwards. - pwd, err := os.Getwd() - if err != nil { - t.Fatal(err) - } - - // Create our deploy environment in a temp dir. - // Defer a call to clean it up. - rootdir := makeDeployEnvironment(t, "") - defer os.RemoveAll(rootdir) - - // Before running the test, chdir into the build environment. - // When we're done, chdir back to our original location. - // This is so we can reliably copy the testdata/ fixtures. - if err := os.Chdir(rootdir); err != nil { - t.Fatal(err) - } - defer os.Chdir(pwd) - - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - buf bytes.Buffer - out io.Writer = common.NewSyncWriter(&buf) - ) - err = app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, out) - testutil.AssertErrorContains(t, err, testcase.wantError) - for _, s := range testcase.wantOutput { - testutil.AssertStringContains(t, buf.String(), s) - } - }) - } -} - -func TestValidate(t *testing.T) { - for _, testcase := range []struct { - name string - args []string - wantError string - wantOutput string - }{ - { - name: "success", - args: []string{"compute", "validate", "-p", "pkg/package.tar.gz"}, - wantError: "", - wantOutput: "Validated package", - }, - } { - t.Run(testcase.name, func(t *testing.T) { - // We're going to chdir to a deploy environment, - // so save the PWD to return to, afterwards. - pwd, err := os.Getwd() - if err != nil { - t.Fatal(err) - } - - // Create our deploy environment in a temp dir. - // Defer a call to clean it up. - rootdir := makeDeployEnvironment(t, "") - defer os.RemoveAll(rootdir) - - // Before running the test, chdir into the build environment. - // When we're done, chdir back to our original location. - // This is so we can reliably copy the testdata/ fixtures. - if err := os.Chdir(rootdir); err != nil { - t.Fatal(err) - } - defer os.Chdir(pwd) - - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(mock.API{}) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - buf bytes.Buffer - out io.Writer = common.NewSyncWriter(&buf) - ) - err = app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertStringContains(t, buf.String(), testcase.wantOutput) - }) - } -} - -func makeInitEnvironment(t *testing.T, manifestContent string) (rootdir string) { - t.Helper() - - rootdir, err := os.MkdirTemp("", "fastly-init-*") - if err != nil { - t.Fatal(err) - } - - if err := os.MkdirAll(rootdir, 0700); err != nil { - t.Fatal(err) - } - - if manifestContent != "" { - filename := filepath.Join(rootdir, compute.ManifestFilename) - if err := os.WriteFile(filename, []byte(manifestContent), 0777); err != nil { - t.Fatal(err) - } - } - - return rootdir -} - -func makeRustBuildEnvironment(t *testing.T, fastlyManifestContent, cargoManifestContent, cargoLockContent string) (rootdir string) { - t.Helper() - - rootdir, err := os.MkdirTemp("", "fastly-build-*") - if err != nil { - t.Fatal(err) - } - - if err := os.MkdirAll(rootdir, 0700); err != nil { - t.Fatal(err) - } - - for _, filename := range [][]string{ - {"Cargo.toml"}, - {"Cargo.lock"}, - {"src", "main.rs"}, - } { - fromFilename := filepath.Join("testdata", "build", filepath.Join(filename...)) - toFilename := filepath.Join(rootdir, filepath.Join(filename...)) - copyFile(t, fromFilename, toFilename) - } - - if fastlyManifestContent != "" { - filename := filepath.Join(rootdir, compute.ManifestFilename) - if err := os.WriteFile(filename, []byte(fastlyManifestContent), 0777); err != nil { - t.Fatal(err) - } - } - - if cargoManifestContent != "" { - filename := filepath.Join(rootdir, "Cargo.toml") - if err := os.WriteFile(filename, []byte(cargoManifestContent), 0777); err != nil { - t.Fatal(err) - } - } - - if cargoLockContent != "" { - filename := filepath.Join(rootdir, "Cargo.lock") - if err := os.WriteFile(filename, []byte(cargoLockContent), 0777); err != nil { - t.Fatal(err) - } - } - - return rootdir -} - -func makeAssemblyScriptBuildEnvironment(t *testing.T, fastlyManifestContent string) (rootdir string) { - t.Helper() - - rootdir, err := os.MkdirTemp("", "fastly-build-*") - if err != nil { - t.Fatal(err) - } - - if err := os.MkdirAll(rootdir, 0700); err != nil { - t.Fatal(err) - } - - for _, filename := range [][]string{ - {"package.json"}, - {"assembly", "index.ts"}, - } { - fromFilename := filepath.Join("testdata", "build", filepath.Join(filename...)) - toFilename := filepath.Join(rootdir, filepath.Join(filename...)) - copyFile(t, fromFilename, toFilename) - } - - if fastlyManifestContent != "" { - filename := filepath.Join(rootdir, compute.ManifestFilename) - if err := os.WriteFile(filename, []byte(fastlyManifestContent), 0777); err != nil { - t.Fatal(err) - } - } - - cmd := exec.Command("npm", "install") - cmd.Dir = rootdir - if err := cmd.Run(); err != nil { - t.Fatal(err) - } - - return rootdir -} - -func makeDeployEnvironment(t *testing.T, manifestContent string) (rootdir string) { - t.Helper() - - rootdir, err := os.MkdirTemp("", "fastly-deploy-*") - if err != nil { - t.Fatal(err) - } - - if err := os.MkdirAll(rootdir, 0700); err != nil { - t.Fatal(err) - } - - for _, filename := range [][]string{ - {"pkg", "package.tar.gz"}, - } { - fromFilename := filepath.Join("testdata", "deploy", filepath.Join(filename...)) - toFilename := filepath.Join(rootdir, filepath.Join(filename...)) - copyFile(t, fromFilename, toFilename) - } - - if manifestContent != "" { - filename := filepath.Join(rootdir, compute.ManifestFilename) - if err := os.WriteFile(filename, []byte(manifestContent), 0777); err != nil { - t.Fatal(err) - } - } - - return rootdir -} - -func makePublishEnvironment(t *testing.T, fastlyManifestContent, cargoManifestContent, cargoLockContent string) (rootdir string) { - t.Helper() - - rootdir, err := os.MkdirTemp("", "fastly-publish-*") - if err != nil { - t.Fatal(err) - } - - if err := os.MkdirAll(rootdir, 0700); err != nil { - t.Fatal(err) - } - - // BUILD REQUIREMENTS - - for _, filename := range [][]string{ - {"Cargo.toml"}, - {"Cargo.lock"}, - {"src", "main.rs"}, - } { - fromFilename := filepath.Join("testdata", "build", filepath.Join(filename...)) - toFilename := filepath.Join(rootdir, filepath.Join(filename...)) - copyFile(t, fromFilename, toFilename) - } - - if fastlyManifestContent != "" { - filename := filepath.Join(rootdir, compute.ManifestFilename) - if err := os.WriteFile(filename, []byte(fastlyManifestContent), 0777); err != nil { - t.Fatal(err) - } - } - - if cargoManifestContent != "" { - filename := filepath.Join(rootdir, "Cargo.toml") - if err := os.WriteFile(filename, []byte(cargoManifestContent), 0777); err != nil { - t.Fatal(err) - } - } - - if cargoLockContent != "" { - filename := filepath.Join(rootdir, "Cargo.lock") - if err := os.WriteFile(filename, []byte(cargoLockContent), 0777); err != nil { - t.Fatal(err) - } - } - - // DEPLOY REQUIREMENTS - - for _, filename := range [][]string{ - {"pkg", "package.tar.gz"}, - } { - fromFilename := filepath.Join("testdata", "deploy", filepath.Join(filename...)) - toFilename := filepath.Join(rootdir, filepath.Join(filename...)) - copyFile(t, fromFilename, toFilename) - } - - return rootdir -} - -func copyFile(t *testing.T, fromFilename, toFilename string) { - t.Helper() - - src, err := os.Open(fromFilename) - if err != nil { - t.Fatal(err) - } - defer src.Close() - - toDir := filepath.Dir(toFilename) - if err := os.MkdirAll(toDir, 0777); err != nil { - t.Fatal(err) - } - - dst, err := os.Create(toFilename) - if err != nil { - t.Fatal(err) - } - - if _, err := io.Copy(dst, src); err != nil { - t.Fatal(err) - } - - if err := dst.Sync(); err != nil { - t.Fatal(err) - } - - if err := dst.Close(); err != nil { - t.Fatal(err) - } -} - -var errTest = errors.New("fixture error") - -func tokenOK() (*fastly.Token, error) { return &fastly.Token{}, nil } - -func getUserOk(i *fastly.GetUserInput) (*fastly.User, error) { - return &fastly.User{Login: "test@example.com"}, nil -} - -func createServiceOK(i *fastly.CreateServiceInput) (*fastly.Service, error) { - return &fastly.Service{ - ID: "12345", - Name: i.Name, - Type: i.Type, - }, nil -} - -func getServiceOK(i *fastly.GetServiceInput) (*fastly.Service, error) { - return &fastly.Service{ - ID: "12345", - Name: "test", - }, nil -} - -func createServiceError(*fastly.CreateServiceInput) (*fastly.Service, error) { - return nil, errTest -} - -func deleteServiceOK(i *fastly.DeleteServiceInput) error { - return nil -} - -func createDomainOK(i *fastly.CreateDomainInput) (*fastly.Domain, error) { - return &fastly.Domain{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: i.Name, - }, nil -} - -func createDomainError(i *fastly.CreateDomainInput) (*fastly.Domain, error) { - return nil, errTest -} - -func deleteDomainOK(i *fastly.DeleteDomainInput) error { - return nil -} - -func createBackendOK(i *fastly.CreateBackendInput) (*fastly.Backend, error) { - return &fastly.Backend{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: i.Name, - }, nil -} - -func createBackendError(i *fastly.CreateBackendInput) (*fastly.Backend, error) { - return nil, errTest -} - -func deleteBackendOK(i *fastly.DeleteBackendInput) error { - return nil -} - -func listVersionsInactiveOk(i *fastly.ListVersionsInput) ([]*fastly.Version, error) { - return []*fastly.Version{ - { - ServiceID: i.ServiceID, - Number: 1, - Active: false, - UpdatedAt: testutil.MustParseTimeRFC3339("2000-01-01T01:00:00Z"), - }, - { - ServiceID: i.ServiceID, - Number: 2, - Active: false, - UpdatedAt: testutil.MustParseTimeRFC3339("2000-01-02T01:00:00Z"), - }, - }, nil -} - -func listVersionsActiveOk(i *fastly.ListVersionsInput) ([]*fastly.Version, error) { - return []*fastly.Version{ - { - ServiceID: i.ServiceID, - Number: 1, - Active: true, - UpdatedAt: testutil.MustParseTimeRFC3339("2000-01-01T01:00:00Z"), - }, - { - ServiceID: i.ServiceID, - Number: 2, - Active: false, - Locked: true, - UpdatedAt: testutil.MustParseTimeRFC3339("2000-01-02T01:00:00Z"), - }, - }, nil -} - -func listVersionsError(i *fastly.ListVersionsInput) ([]*fastly.Version, error) { - return nil, errTest -} - -func getPackageOk(i *fastly.GetPackageInput) (*fastly.Package, error) { - return &fastly.Package{ServiceID: i.ServiceID, ServiceVersion: i.ServiceVersion}, nil -} - -func getPackageIdentical(i *fastly.GetPackageInput) (*fastly.Package, error) { - return &fastly.Package{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Metadata: fastly.PackageMetadata{ - HashSum: "2b742f99854df7e024c287e36fb0fdfc5414942e012be717e52148ea0d6800d66fc659563f6f11105815051e82b14b61edc84b33b49789b790db1ed3446fb483", - }, - }, nil -} - -func cloneVersionOk(i *fastly.CloneVersionInput) (*fastly.Version, error) { - return &fastly.Version{ServiceID: i.ServiceID, Number: i.ServiceVersion + 1}, nil -} - -func cloneVersionError(i *fastly.CloneVersionInput) (*fastly.Version, error) { - return nil, errTest -} - -func updatePackageOk(i *fastly.UpdatePackageInput) (*fastly.Package, error) { - return &fastly.Package{ServiceID: i.ServiceID, ServiceVersion: i.ServiceVersion}, nil -} - -func updatePackageError(i *fastly.UpdatePackageInput) (*fastly.Package, error) { - return nil, errTest -} - -func activateVersionOk(i *fastly.ActivateVersionInput) (*fastly.Version, error) { - return &fastly.Version{ServiceID: i.ServiceID, Number: i.ServiceVersion}, nil -} - -func activateVersionError(i *fastly.ActivateVersionInput) (*fastly.Version, error) { - return nil, errTest -} - -func listDomainsOk(i *fastly.ListDomainsInput) ([]*fastly.Domain, error) { - return []*fastly.Domain{ - {Name: "https://directly-careful-coyote.edgecompute.app"}, - }, nil -} - -func listDomainsError(i *fastly.ListDomainsInput) ([]*fastly.Domain, error) { - return nil, errTest -} - -func listBackendsOk(i *fastly.ListBackendsInput) ([]*fastly.Backend, error) { - return []*fastly.Backend{ - {Name: "foobar"}, - }, nil -} - -func listBackendsError(i *fastly.ListBackendsInput) ([]*fastly.Backend, error) { - return nil, errTest -} - -type versionClient struct { - fastlyVersions []string - fastlySysVersions []string -} - -func (v versionClient) Do(req *http.Request) (*http.Response, error) { - var vs []string - - if strings.Contains(req.URL.String(), "crates/fastly-sys/") { - vs = v.fastlySysVersions - } - if strings.Contains(req.URL.String(), "crates/fastly/") { - vs = v.fastlyVersions - } - - rec := httptest.NewRecorder() - - var versions []string - for _, vv := range vs { - versions = append(versions, fmt.Sprintf(`{"num":"%s"}`, vv)) - } - - _, err := rec.Write([]byte(fmt.Sprintf(`{"versions":[%s]}`, strings.Join(versions, ",")))) - if err != nil { - return nil, err - } - return rec.Result(), nil -} diff --git a/pkg/compute/compute_test.go b/pkg/compute/compute_test.go deleted file mode 100644 index 2a30379b8..000000000 --- a/pkg/compute/compute_test.go +++ /dev/null @@ -1,584 +0,0 @@ -package compute - -import ( - "crypto/rand" - "errors" - "fmt" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "reflect" - "sort" - "strings" - "testing" - - "github.com/Masterminds/semver/v3" - "github.com/fastly/cli/pkg/api" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/filesystem" - "github.com/fastly/cli/pkg/testutil" - "github.com/fastly/go-fastly/v3/fastly" - "github.com/fastly/kingpin" - "github.com/mholt/archiver/v3" -) - -// TestPublishFlagDivergence validates that the manually curated list of flags -// within the `compute publish` command doesn't fall out of sync with the -// `compute build` and `compute deploy` commands from which publish is composed. -func TestPublishFlagDivergence(t *testing.T) { - var cfg config.Data - acmd := kingpin.New("foo", "bar") - - rcmd := NewRootCommand(acmd, &cfg) - bcmd := NewBuildCommand(rcmd.CmdClause, client{}, &cfg) - dcmd := NewDeployCommand(rcmd.CmdClause, client{}, &cfg) - pcmd := NewPublishCommand(rcmd.CmdClause, &cfg, bcmd, dcmd) - - buildFlags := getFlags(bcmd.CmdClause) - deployFlags := getFlags(dcmd.CmdClause) - publishFlags := getFlags(pcmd.CmdClause) - - var ( - expect []string - have []string - ) - - iter := buildFlags.MapRange() - for iter.Next() { - expect = append(expect, fmt.Sprintf("%s", iter.Key())) - } - iter = deployFlags.MapRange() - for iter.Next() { - expect = append(expect, fmt.Sprintf("%s", iter.Key())) - } - - iter = publishFlags.MapRange() - for iter.Next() { - have = append(have, fmt.Sprintf("%s", iter.Key())) - } - - sort.Strings(expect) - sort.Strings(have) - - errMsg := "the flags between build/deploy and publish don't match" - - if len(expect) != len(have) { - t.Fatal(errMsg) - } - - for i, v := range expect { - if have[i] != v { - t.Fatalf("%s, expected: %s, got: %s", errMsg, v, have[i]) - } - } -} - -type client struct{} - -func (c client) Do(*http.Request) (*http.Response, error) { - var resp http.Response - return &resp, nil -} - -func getFlags(cmd *kingpin.CmdClause) reflect.Value { - return reflect.ValueOf(cmd).Elem().FieldByName("cmdMixin").FieldByName("flagGroup").Elem().FieldByName("long") -} - -func TestCreatePackageArchive(t *testing.T) { - for _, testcase := range []struct { - name string - destination string - inputFiles []string - wantDirectories []string - wantFiles []string - }{ - { - name: "success", - destination: "cli.tar.gz", - inputFiles: []string{ - "Cargo.toml", - "Cargo.lock", - "src/main.rs", - }, - wantDirectories: []string{ - "cli", - "src", - }, - wantFiles: []string{ - "Cargo.lock", - "Cargo.toml", - "main.rs", - }, - }, - } { - t.Run(testcase.name, func(t *testing.T) { - // we're going to chdir to a build environment, - // so save the pwd to return to, afterwards. - pwd, err := os.Getwd() - if err != nil { - t.Fatal(err) - } - - // create our build environment in a temp dir. - // defer a call to clean it up. - rootdir := makeBuildEnvironment(t, "") - defer os.RemoveAll(rootdir) - - // before running the test, chdir into the build environment. - // when we're done, chdir back to our original location. - // this is so we can reliably copy the testdata/ fixtures. - if err := os.Chdir(rootdir); err != nil { - t.Fatal(err) - } - defer os.Chdir(pwd) - - err = createPackageArchive(testcase.inputFiles, testcase.destination) - testutil.AssertNoError(t, err) - - var files, directories []string - if err := archiver.Walk(testcase.destination, func(f archiver.File) error { - if f.IsDir() { - directories = append(directories, f.Name()) - } else { - files = append(files, f.Name()) - } - return nil - }); err != nil { - t.Fatal(err) - } - - testutil.AssertEqual(t, testcase.wantDirectories, directories) - testutil.AssertEqual(t, testcase.wantFiles, files) - }) - } -} - -func TestFileNameWithoutExtension(t *testing.T) { - for _, testcase := range []struct { - input string - wantOutput string - }{ - { - input: "foo/bar/baz.tar.gz", - wantOutput: "baz", - }, - { - input: "foo/bar/baz.wasm", - wantOutput: "baz", - }, - { - input: "foo.tar", - wantOutput: "foo", - }, - } { - t.Run(testcase.input, func(t *testing.T) { - output := fileNameWithoutExtension(testcase.input) - testutil.AssertString(t, testcase.wantOutput, output) - }) - } -} - -func TestGetIgnoredFiles(t *testing.T) { - for _, testcase := range []struct { - name string - fastlyignore string - wantfiles map[string]bool - }{ - { - name: "ignore src", - fastlyignore: "src/*", - wantfiles: map[string]bool{ - filepath.Join("src/main.rs"): true, - }, - }, - { - name: "ignore cargo files", - fastlyignore: "Cargo.*", - wantfiles: map[string]bool{ - "Cargo.lock": true, - "Cargo.toml": true, - }, - }, - { - name: "ignore all", - fastlyignore: "*", - wantfiles: map[string]bool{ - ".fastlyignore": true, - "Cargo.lock": true, - "Cargo.toml": true, - "src": true, - }, - }, - } { - t.Run(testcase.name, func(t *testing.T) { - // we're going to chdir to a build environment, - // so save the pwd to return to, afterwards. - pwd, err := os.Getwd() - if err != nil { - t.Fatal(err) - } - - // create our build environment in a temp dir. - // defer a call to clean it up. - rootdir := makeBuildEnvironment(t, testcase.fastlyignore) - defer os.RemoveAll(rootdir) - - // before running the test, chdir into the build environment. - // when we're done, chdir back to our original location. - // this is so we can reliably copy the testdata/ fixtures. - if err := os.Chdir(rootdir); err != nil { - t.Fatal(err) - } - defer os.Chdir(pwd) - - output, err := getIgnoredFiles(IgnoreFilePath) - testutil.AssertNoError(t, err) - testutil.AssertEqual(t, testcase.wantfiles, output) - }) - } -} - -func TestGetNonIgnoredFiles(t *testing.T) { - for _, testcase := range []struct { - name string - path string - ignoredFiles map[string]bool - wantFiles []string - }{ - { - name: "no ignored files", - path: ".", - ignoredFiles: map[string]bool{}, - wantFiles: []string{ - "Cargo.lock", - "Cargo.toml", - filepath.Join("src/main.rs"), - }, - }, - { - name: "one ignored file", - path: ".", - ignoredFiles: map[string]bool{ - filepath.Join("src/main.rs"): true, - }, - wantFiles: []string{ - "Cargo.lock", - "Cargo.toml", - }, - }, - { - name: "multiple ignored files", - path: ".", - ignoredFiles: map[string]bool{ - "Cargo.toml": true, - "Cargo.lock": true, - }, - wantFiles: []string{ - filepath.Join("src/main.rs"), - }, - }, - } { - t.Run(testcase.name, func(t *testing.T) { - // We're going to chdir to a build environment, - // so save the PWD to return to, afterwards. - pwd, err := os.Getwd() - if err != nil { - t.Fatal(err) - } - - // Create our build environment in a temp dir. - // Defer a call to clean it up. - rootdir := makeBuildEnvironment(t, "") - defer os.RemoveAll(rootdir) - - // Before running the test, chdir into the build environment. - // When we're done, chdir back to our original location. - // This is so we can reliably copy the testdata/ fixtures. - if err := os.Chdir(rootdir); err != nil { - t.Fatal(err) - } - defer os.Chdir(pwd) - - output, err := getNonIgnoredFiles(testcase.path, testcase.ignoredFiles) - testutil.AssertNoError(t, err) - testutil.AssertEqual(t, testcase.wantFiles, output) - }) - } -} - -func TestGetIdealPackage(t *testing.T) { - for _, testcase := range []struct { - name string - inputVersions []*fastly.Version - wantVersion int - }{ - { - name: "active", - inputVersions: []*fastly.Version{ - {Number: 1, Active: false, UpdatedAt: testutil.MustParseTimeRFC3339("2000-01-01T01:00:00Z")}, - {Number: 2, Active: true, UpdatedAt: testutil.MustParseTimeRFC3339("2000-01-02T01:00:00Z")}, - }, - wantVersion: 2, - }, - { - name: "active not latest", - inputVersions: []*fastly.Version{ - {Number: 1, Active: false, UpdatedAt: testutil.MustParseTimeRFC3339("2000-01-01T01:00:00Z")}, - {Number: 2, Active: true, UpdatedAt: testutil.MustParseTimeRFC3339("2000-01-02T01:00:00Z")}, - {Number: 3, Active: false, UpdatedAt: testutil.MustParseTimeRFC3339("2000-01-03T01:00:00Z")}, - }, - wantVersion: 2, - }, - { - name: "active and locked", - inputVersions: []*fastly.Version{ - {Number: 1, Active: false, UpdatedAt: testutil.MustParseTimeRFC3339("2000-01-01T01:00:00Z")}, - {Number: 2, Active: true, UpdatedAt: testutil.MustParseTimeRFC3339("2000-01-02T01:00:00Z")}, - {Number: 3, Active: false, Locked: true, UpdatedAt: testutil.MustParseTimeRFC3339("2000-01-03T01:00:00Z")}}, - wantVersion: 2, - }, - { - name: "locked", - inputVersions: []*fastly.Version{ - {Number: 1, Active: false, UpdatedAt: testutil.MustParseTimeRFC3339("2000-01-01T01:00:00Z")}, - {Number: 2, Active: false, Locked: true, UpdatedAt: testutil.MustParseTimeRFC3339("2000-01-02T01:00:00Z")}, - }, - wantVersion: 2, - }, - { - name: "locked not latest", - inputVersions: []*fastly.Version{ - {Number: 1, Active: false, UpdatedAt: testutil.MustParseTimeRFC3339("2000-01-01T01:00:00Z")}, - {Number: 2, Active: false, Locked: true, UpdatedAt: testutil.MustParseTimeRFC3339("2000-01-02T01:00:00Z")}, - {Number: 3, Active: false, UpdatedAt: testutil.MustParseTimeRFC3339("2000-01-03T01:00:00Z")}, - }, - wantVersion: 2, - }, - { - name: "no active or locked", - inputVersions: []*fastly.Version{ - {Number: 1, Active: false, UpdatedAt: testutil.MustParseTimeRFC3339("2000-01-01T01:00:00Z")}, - {Number: 2, Active: false, UpdatedAt: testutil.MustParseTimeRFC3339("2000-01-02T01:00:00Z")}, - {Number: 3, Active: false, UpdatedAt: testutil.MustParseTimeRFC3339("2000-01-03T01:00:00Z")}, - }, - wantVersion: 3, - }, - { - name: "not sorted", - inputVersions: []*fastly.Version{ - {Number: 3, Active: false, UpdatedAt: testutil.MustParseTimeRFC3339("2000-01-03T01:00:00Z")}, - {Number: 2, Active: false, UpdatedAt: testutil.MustParseTimeRFC3339("2000-01-02T01:00:00Z")}, - {Number: 4, Active: false, UpdatedAt: testutil.MustParseTimeRFC3339("2000-01-04T01:00:00Z")}, - {Number: 1, Active: false, UpdatedAt: testutil.MustParseTimeRFC3339("2000-01-01T01:00:00Z")}, - }, - wantVersion: 4, - }, - } { - t.Run(testcase.name, func(t *testing.T) { - v, err := getLatestIdealVersion(testcase.inputVersions) - testutil.AssertNoError(t, err) - if v.Number != testcase.wantVersion { - t.Errorf("wanted version %d, got %d", testcase.wantVersion, v.Number) - } - }) - } -} - -func TestGetLatestCrateVersion(t *testing.T) { - for _, testcase := range []struct { - name string - inputClient api.HTTPClient - wantVersion *semver.Version - wantError string - }{ - { - name: "http error", - inputClient: &errorClient{errTest}, - wantError: "fixture error", - }, - { - name: "no valid versions", - inputClient: &versionClient{[]string{}}, - wantError: "no valid crate versions found", - }, - { - name: "unsorted", - inputClient: &versionClient{[]string{"0.5.23", "0.1.0", "1.2.3", "0.7.3"}}, - wantVersion: semver.MustParse("1.2.3"), - }, - { - name: "reverse chronological", - inputClient: &versionClient{[]string{"1.2.3", "0.8.3", "0.3.2"}}, - wantVersion: semver.MustParse("1.2.3"), - }, - { - name: "contains pre-release", - inputClient: &versionClient{[]string{"0.2.3", "0.8.3", "0.3.2", "0.9.0-beta.2"}}, - wantVersion: semver.MustParse("0.8.3"), - }, - } { - t.Run(testcase.name, func(t *testing.T) { - v, err := getLatestCrateVersion(testcase.inputClient, "fastly") - testutil.AssertErrorContains(t, err, testcase.wantError) - if err == nil && !v.Equal(testcase.wantVersion) { - t.Errorf("wanted version %s, got %s", testcase.wantVersion, v) - } - }) - } -} - -func TestGetCrateVersionFromMetadata(t *testing.T) { - for _, testcase := range []struct { - name string - inputLock CargoMetadata - inputCrate string - wantVersion *semver.Version - wantError string - }{ - { - name: "crate not found", - inputLock: CargoMetadata{}, - inputCrate: "fastly", - wantError: "fastly crate not found", - }, - { - name: "parsing error", - inputLock: CargoMetadata{ - Package: []CargoPackage{ - { - Name: "foo", - Version: "1.2.3", - }, - { - Name: "fastly", - Version: "dgsfdfg", - }, - }, - }, - inputCrate: "fastly", - wantError: "error parsing cargo metadata", - }, - { - name: "success", - inputLock: CargoMetadata{ - Package: []CargoPackage{ - { - Name: "foo", - Version: "1.2.3", - }, - { - Name: "fastly-sys", - Version: "0.3.0", - }, - { - Name: "fastly", - Version: "3.0.0", - }, - }, - }, - inputCrate: "fastly", - wantVersion: semver.MustParse("3.0.0"), - }, - { - name: "success nested", - inputLock: CargoMetadata{ - Package: []CargoPackage{ - { - Name: "foo", - Version: "1.2.3", - }, - { - Name: "fastly", - Version: "3.0.0", - Dependencies: []CargoPackage{ - { - Name: "fastly-sys", - Version: "0.3.0", - }, - }, - }, - }, - }, - inputCrate: "fastly-sys", - wantVersion: semver.MustParse("0.3.0"), - }, - } { - t.Run(testcase.name, func(t *testing.T) { - v, err := getCrateVersionFromMetadata(testcase.inputLock, testcase.inputCrate) - testutil.AssertErrorContains(t, err, testcase.wantError) - if err == nil && !v.Equal(testcase.wantVersion) { - t.Errorf("wanted version %s, got %s", testcase.wantVersion, v) - } - }) - } -} - -func makeBuildEnvironment(t *testing.T, fastlyIgnoreContent string) (rootdir string) { - t.Helper() - - p := make([]byte, 8) - n, err := rand.Read(p) - if err != nil { - t.Fatal(err) - } - - rootdir = filepath.Join( - os.TempDir(), - fmt.Sprintf("fastly-build-%x", p[:n]), - ) - - if err := os.MkdirAll(rootdir, 0700); err != nil { - t.Fatal(err) - } - - for _, filename := range [][]string{ - {"Cargo.toml"}, - {"Cargo.lock"}, - {"src", "main.rs"}, - } { - fromFilename := filepath.Join("testdata", "build", filepath.Join(filename...)) - toFilename := filepath.Join(rootdir, filepath.Join(filename...)) - if err := filesystem.CopyFile(fromFilename, toFilename); err != nil { - t.Fatal(err) - } - } - - if fastlyIgnoreContent != "" { - filename := filepath.Join(rootdir, IgnoreFilePath) - if err := os.WriteFile(filename, []byte(fastlyIgnoreContent), 0777); err != nil { - t.Fatal(err) - } - } - - return rootdir -} - -var errTest = errors.New("fixture error") - -type errorClient struct { - err error -} - -func (c errorClient) Do(*http.Request) (*http.Response, error) { - return nil, c.err -} - -type versionClient struct { - versions []string -} - -func (v versionClient) Do(*http.Request) (*http.Response, error) { - rec := httptest.NewRecorder() - - var versions []string - for _, vv := range v.versions { - versions = append(versions, fmt.Sprintf(`{"num":"%s"}`, vv)) - } - - _, err := rec.Write([]byte(fmt.Sprintf(`{"versions":[%s]}`, strings.Join(versions, ",")))) - if err != nil { - return nil, err - } - return rec.Result(), nil -} diff --git a/pkg/compute/deploy.go b/pkg/compute/deploy.go deleted file mode 100644 index 1565d64a7..000000000 --- a/pkg/compute/deploy.go +++ /dev/null @@ -1,691 +0,0 @@ -package compute - -import ( - "crypto/sha512" - "fmt" - "io" - "math/rand" - "net" - "os" - "path/filepath" - "sort" - "strconv" - "strings" - "time" - - petname "github.com/dustinkirkland/golang-petname" - "github.com/fastly/cli/pkg/api" - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" - "github.com/kennygrant/sanitize" -) - -type invalidResource int - -const ( - defaultTopLevelDomain = "edgecompute.app" - manageServiceBaseURL = "https://manage.fastly.com/configure/services/" - resourceNone invalidResource = iota - resourceBoth - resourceDomain - resourceBackend -) - -// DeployCommand deploys an artifact previously produced by build. -type DeployCommand struct { - common.Base - manifest manifest.Data - - // NOTE: these are public so that the "publish" composite command can set the - // values appropriately before calling the Exec() function. - Path string - Version common.OptionalInt - Domain string - Backend string - BackendPort uint -} - -// NewDeployCommand returns a usable command registered under the parent. -func NewDeployCommand(parent common.Registerer, client api.HTTPClient, globals *config.Data) *DeployCommand { - var c DeployCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("deploy", "Deploy a package to a Fastly Compute@Edge service") - - // NOTE: when updating these flags, be sure to update the composite command: - // `compute publish`. - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("version", "Number of version to activate").Action(c.Version.Set).IntVar(&c.Version.Value) - c.CmdClause.Flag("path", "Path to package").Short('p').StringVar(&c.Path) - c.CmdClause.Flag("domain", "The name of the domain associated to the package").StringVar(&c.Domain) - c.CmdClause.Flag("backend", "A hostname, IPv4, or IPv6 address for the package backend").StringVar(&c.Backend) - c.CmdClause.Flag("backend-port", "A port number for the package backend").UintVar(&c.BackendPort) - - return &c -} - -// Exec implements the command interface. -func (c *DeployCommand) Exec(in io.Reader, out io.Writer) (err error) { - // Exit early if no token configured. - _, s := c.Globals.Token() - if s == config.SourceUndefined { - return errors.ErrNoToken - } - - var ( - domain, backend string - backendPort uint - invalidService bool - invalidType invalidResource - version *fastly.Version - ) - - serviceID, sidSrc := c.manifest.ServiceID() - if sidSrc == manifest.SourceUndefined { - text.Output(out, "There is no Fastly service associated with this package. To connect to an existing service add the Service ID to the fastly.toml file, otherwise follow the prompts to create a service now.") - text.Break(out) - text.Output(out, "Press ^C at any time to quit.") - text.Break(out) - - domain, err = cfgDomain(c.Domain, defaultTopLevelDomain, out, in, validateDomain) - if err != nil { - return err - } - - backend, backendPort, err = cfgBackend(c.Backend, c.BackendPort, out, in, validateBackend) - if err != nil { - return err - } - - text.Break(out) - } else { - // We define the `ok` variable so that the following call to - // `validateservice` will be able to shadow multiple variables. - var ok bool - - // Because a service_id exists in the fastly.toml doesn't mean it's valid - // e.g. it could be missing either a domain or backend resource. So we - // check and allow the user to configure these settings before continuing. - // - // The returned version will only be nil if the initial service version - // request failed. - version, ok, invalidType, err = validateService(serviceID, c.Globals.Client, c.Version) - if err != nil { - return err - } - - if !ok { - invalidService = true - - text.Output(out, "Service '%s' is missing required domain or backend. These must be added before the Compute@Edge service can be deployed.", serviceID) - text.Break(out) - - switch invalidType { - case resourceBoth: - domain, err = cfgDomain(c.Domain, defaultTopLevelDomain, out, in, validateDomain) - if err != nil { - return err - } - backend, backendPort, err = cfgBackend(c.Backend, c.BackendPort, out, in, validateBackend) - if err != nil { - return err - } - case resourceDomain: - domain, err = cfgDomain(c.Domain, defaultTopLevelDomain, out, in, validateDomain) - if err != nil { - return err - } - case resourceBackend: - backend, backendPort, err = cfgBackend(c.Backend, c.BackendPort, out, in, validateBackend) - if err != nil { - return err - } - } - - text.Break(out) - } - } - - var ( - progress text.Progress - desc string - ) - - if c.Globals.Verbose() { - progress = text.NewVerboseProgress(out) - } else { - progress = text.NewQuietProgress(out) - } - - undoStack := common.NewUndoStack() - - defer func() { - if err != nil { - progress.Fail() // progress.Done is handled inline - } - undoStack.RunIfError(out, err) - }() - - name, source := c.manifest.Name() - path, err := pkgPath(c.Path, progress, name, source) - if err != nil { - return err - } - - if sidSrc == manifest.SourceUndefined { - // There is no service and so we'll do a one time creation of the service - // and the associated domain/backend and store the Service ID within the - // manifest. On subsequent runs of the deploy subcommand we'll skip the - // service/domain/backend creation. - // - // NOTE: we're shadowing the `version` and `serviceID` variable. - version, serviceID, err = createService(progress, c.Globals.Client, name, desc) - if err != nil { - return err - } - - undoStack.Push(func() error { - return c.Globals.Client.DeleteService(&fastly.DeleteServiceInput{ - ID: serviceID, - }) - }) - - // We can't create the domain/backend earlier in the logic flow as it - // requires the use of a text.Progress which overwrites the current line - // (i.e. it would cause any text prompts to be hidden) and so we prompt for - // as much information as possible at the top of the Exec function. After - // we have all the information, then we proceed with the creation of resources. - err = createDomain(progress, c.Globals.Client, serviceID, version.Number, domain, undoStack) - if err != nil { - return err - } - err = createBackend(progress, c.Globals.Client, serviceID, version.Number, backend, backendPort, undoStack) - if err != nil { - return err - } - } - - // If the user has specified a Service ID, then we validate it has the - // required resources, and if it's invalid we'll drop into the following code - // block to ensure we only create the resources that are missing. - if invalidService { - switch invalidType { - case resourceBoth: - err = createDomain(progress, c.Globals.Client, serviceID, version.Number, domain, undoStack) - if err != nil { - return err - } - err = createBackend(progress, c.Globals.Client, serviceID, version.Number, backend, backendPort, undoStack) - if err != nil { - return err - } - case resourceDomain: - err = createDomain(progress, c.Globals.Client, serviceID, version.Number, domain, undoStack) - if err != nil { - return err - } - case resourceBackend: - err = createBackend(progress, c.Globals.Client, serviceID, version.Number, backend, backendPort, undoStack) - if err != nil { - return err - } - } - } - - // We only want to update the manifest if it's the first time deploying and - // so we will have only just created a new service. If the Service ID is - // provided by the flag and not the file, then we'll also store that ID - // within the manifest. - if sidSrc == manifest.SourceUndefined || sidSrc != manifest.SourceFile { - err = updateManifestServiceID(ManifestFilename, progress, serviceID) - if err != nil { - return err - } - } - - progress.Step("Validating package...") - if err := validate(path); err != nil { - return err - } - - cont, err := pkgCompare(c.Globals.Client, serviceID, version.Number, path, progress, out) - if err != nil { - return err - } - if !cont { - return nil - } - - err = pkgUpload(progress, c.Globals.Client, serviceID, version.Number, path) - if err != nil { - return err - } - - progress.Step("Activating version...") - - _, err = c.Globals.Client.ActivateVersion(&fastly.ActivateVersionInput{ - ServiceID: serviceID, - ServiceVersion: version.Number, - }) - if err != nil { - return fmt.Errorf("error activating version: %w", err) - } - - progress.Done() - - text.Break(out) - - text.Description(out, "Manage this service at", fmt.Sprintf("%s%s", manageServiceBaseURL, serviceID)) - - domains, err := c.Globals.Client.ListDomains(&fastly.ListDomainsInput{ - ServiceID: serviceID, - ServiceVersion: version.Number, - }) - if err == nil { - text.Description(out, "View this service at", fmt.Sprintf("https://%s", domains[0].Name)) - } - - text.Success(out, "Deployed package (service %s, version %v)", serviceID, version.Number) - return nil -} - -// pkgPath generates a path that points to a package tar inside the pkg -// directory if the `path` flag was not set by the user. -func pkgPath(path string, progress text.Progress, name string, source manifest.Source) (string, error) { - if path == "" { - progress.Step("Reading package manifest...") - - if source == manifest.SourceUndefined { - return "", errors.RemediationError{ - Inner: fmt.Errorf("error reading package manifest"), - Remediation: "Run `fastly compute init` to ensure a correctly configured manifest. See more at https://developer.fastly.com/reference/fastly-toml/", - } - } - - path = filepath.Join("pkg", fmt.Sprintf("%s.tar.gz", sanitize.BaseName(name))) - - return path, nil - } - - return path, nil -} - -// validateService checks if the service version has a domain and backend -// defined. -func validateService(serviceID string, client api.Interface, version common.OptionalInt) (*fastly.Version, bool, invalidResource, error) { - v, err := serviceVersion(serviceID, client, version) - if err != nil { - return nil, false, resourceNone, err - } - - domains, err := client.ListDomains(&fastly.ListDomainsInput{ - ServiceID: serviceID, - ServiceVersion: v.Number, - }) - if err != nil { - return v, false, resourceNone, fmt.Errorf("error fetching service domains: %w", err) - } - - backends, err := client.ListBackends(&fastly.ListBackendsInput{ - ServiceID: serviceID, - ServiceVersion: v.Number, - }) - if err != nil { - return v, false, resourceNone, fmt.Errorf("error fetching service backends: %w", err) - } - - ld := len(domains) - lb := len(backends) - - if ld == 0 && lb == 0 { - return v, false, resourceBoth, nil - } - if ld == 0 { - return v, false, resourceDomain, nil - } - if lb == 0 { - return v, false, resourceBackend, nil - } - - return v, true, resourceNone, nil -} - -// serviceVersion returns the version for the given service. -func serviceVersion(serviceID string, client api.Interface, versionFlag common.OptionalInt) (*fastly.Version, error) { - _, err := client.GetService(&fastly.GetServiceInput{ - ID: serviceID, - }) - if err != nil { - return nil, fmt.Errorf("error fetching service details: %w", err) - } - - var version *fastly.Version - - if versionFlag.WasSet { - version = &fastly.Version{Number: versionFlag.Value} - } else { - var err error - version, err = pkgVersion(serviceID, client) - if err != nil { - return nil, err - } - } - - return version, nil -} - -// pkgVersion acquires the ideal version to associate with a compute package. -func pkgVersion(serviceID string, client api.Interface) (*fastly.Version, error) { - versions, err := client.ListVersions(&fastly.ListVersionsInput{ - ServiceID: serviceID, - }) - if err != nil { - return nil, fmt.Errorf("error listing service versions: %w", err) - } - - version, err := getLatestIdealVersion(versions) - if err != nil { - return nil, fmt.Errorf("error finding latest service version") - } - - if version.Active || version.Locked { - version, err := client.CloneVersion(&fastly.CloneVersionInput{ - ServiceID: serviceID, - ServiceVersion: version.Number, - }) - if err != nil { - return nil, fmt.Errorf("error cloning latest service version: %w", err) - } - - return version, nil - } - - return version, nil -} - -// createService creates a service to associate with the compute package. -func createService(progress text.Progress, client api.Interface, name string, desc string) (*fastly.Version, string, error) { - progress.Step("Creating service...") - - service, err := client.CreateService(&fastly.CreateServiceInput{ - Name: name, - Type: "wasm", - Comment: desc, - }) - if err != nil { - if strings.Contains(err.Error(), "Valid values for 'type' are: 'vcl'") { - return nil, "", errors.RemediationError{ - Inner: fmt.Errorf("error creating service: you do not have the Compute@Edge feature flag enabled on your Fastly account"), - Remediation: "See more at https://fastly.dev/learning/compute/#create-a-new-fastly-account-and-invite-your-collaborators", - } - } - return nil, "", fmt.Errorf("error creating service: %w", err) - } - - version := &fastly.Version{Number: 1} - serviceID := service.ID - - return version, serviceID, nil -} - -// updateManifestServiceID updates the Service ID in the manifest. -func updateManifestServiceID(manifestFilename string, progress text.Progress, serviceID string) error { - var m manifest.File - - if err := m.Read(manifestFilename); err != nil { - return fmt.Errorf("error reading package manifest: %w", err) - } - - fmt.Fprintf(progress, "Setting service ID in manifest to %q...\n", serviceID) - - m.ServiceID = serviceID - - if err := m.Write(manifestFilename); err != nil { - return fmt.Errorf("error saving package manifest: %w", err) - } - - return nil -} - -// pkgCompare compares the local package hashsum against the existing service -// package version and exits early with message if identical. -func pkgCompare(client api.Interface, serviceID string, version int, path string, progress text.Progress, out io.Writer) (bool, error) { - p, err := client.GetPackage(&fastly.GetPackageInput{ - ServiceID: serviceID, - ServiceVersion: version, - }) - - if err == nil { - hashSum, err := getHashSum(path) - if err != nil { - return false, fmt.Errorf("error getting package hashsum: %w", err) - } - - if hashSum == p.Metadata.HashSum { - progress.Done() - text.Info(out, "Skipping package deployment, local and service version are identical. (service %v, version %v) ", serviceID, version) - return false, nil - } - } - - return true, nil -} - -// pkgUpload uploads the package to the specified service and version. -func pkgUpload(progress text.Progress, client api.Interface, serviceID string, version int, path string) error { - progress.Step("Uploading package...") - - _, err := client.UpdatePackage(&fastly.UpdatePackageInput{ - ServiceID: serviceID, - ServiceVersion: version, - PackagePath: path, - }) - - if err != nil { - return fmt.Errorf("error uploading package: %w", err) - } - - return nil -} - -// cfgDomain configures the domain value. -func cfgDomain(domain string, def string, out io.Writer, in io.Reader, f validator) (string, error) { - if domain != "" { - return domain, nil - } - - rand.Seed(time.Now().UnixNano()) - - defaultDomain := fmt.Sprintf("%s.%s", petname.Generate(3, "-"), def) - domain, err := text.Input(out, fmt.Sprintf("Domain: [%s] ", defaultDomain), in, f) - if err != nil { - return "", fmt.Errorf("error reading input %w", err) - } - - if domain == "" { - return defaultDomain, nil - } - - return domain, nil -} - -// cfgBackend configures the backend address and its port number values. -func cfgBackend(backend string, backendPort uint, out io.Writer, in io.Reader, f validator) (string, uint, error) { - if backend == "" { - var err error - backend, err = text.Input(out, "Backend (originless, hostname or IP address): [originless] ", in, f) - - if err != nil { - return "", 0, fmt.Errorf("error reading input %w", err) - } - - if backend == "" || backend == "originless" { - backend = "127.0.0.1" - backendPort = uint(80) - } - } - - if backendPort == 0 { - input, err := text.Input(out, "Backend port number: [80] ", in) - if err != nil { - return "", 0, fmt.Errorf("error reading input %w", err) - } - - portnumber, err := strconv.Atoi(input) - if err != nil { - text.Warning(out, "error converting input: %v. We'll use the default port number: [80].", err) - portnumber = 80 - } - - backendPort = uint(portnumber) - } - - return backend, backendPort, nil -} - -// createDomain creates the given domain and handle unrolling the stack in case -// of an error (i.e. will ensure the domain is deleted if there is an error). -func createDomain(progress text.Progress, client api.Interface, serviceID string, version int, domain string, undoStack common.Undoer) error { - progress.Step("Creating domain...") - - undoStack.Push(func() error { - return client.DeleteDomain(&fastly.DeleteDomainInput{ - ServiceID: serviceID, - ServiceVersion: version, - Name: domain, - }) - }) - - _, err := client.CreateDomain(&fastly.CreateDomainInput{ - ServiceID: serviceID, - ServiceVersion: version, - Name: domain, - }) - if err != nil { - return fmt.Errorf("error creating domain: %w", err) - } - - return nil -} - -// createBackend creates the given domain and handle unrolling the stack in case -// of an error (i.e. will ensure the backend is deleted if there is an error). -func createBackend(progress text.Progress, client api.Interface, serviceID string, version int, backend string, backendPort uint, undoStack common.Undoer) error { - progress.Step("Creating backend...") - - undoStack.Push(func() error { - return client.DeleteBackend(&fastly.DeleteBackendInput{ - ServiceID: serviceID, - ServiceVersion: version, - Name: backend, - }) - }) - - _, err := client.CreateBackend(&fastly.CreateBackendInput{ - ServiceID: serviceID, - ServiceVersion: version, - Name: backend, - Address: backend, - Port: backendPort, - }) - if err != nil { - return fmt.Errorf("error creating backend: %w", err) - } - - return nil -} - -// getLatestIdealVersion gets the most ideal service version using the following logic: -// - Find the active version and return -// - If no active version, find the latest locked version and return -// - Otherwise return the latest version -func getLatestIdealVersion(versions []*fastly.Version) (*fastly.Version, error) { - sort.Slice(versions, func(i, j int) bool { - return versions[i].UpdatedAt.Before(*versions[j].UpdatedAt) - }) - - var active, locked, latest *fastly.Version - for i := 0; i < len(versions); i++ { - v := versions[i] - if v.Active { - active = v - } - if v.Locked { - locked = v - } - latest = v - } - - var version *fastly.Version - if active != nil { - version = active - } else if locked != nil { - version = locked - } else { - version = latest - } - - if version == nil { - return nil, fmt.Errorf("error finding latest service version") - } - - return version, nil -} - -func getHashSum(path string) (hash string, err error) { - // gosec flagged this: - // G304 (CWE-22): Potential file inclusion via variable - // Disabling as we trust the source of the filepath variable. - /* #nosec */ - f, err := os.Open(path) - if err != nil { - return "", err - } - defer func() { - cerr := f.Close() - if err == nil { - err = cerr - } - }() - - h := sha512.New() - if _, err := io.Copy(h, f); err != nil { - return "", err - } - - return fmt.Sprintf("%x", h.Sum(nil)), nil -} - -type validator func(input string) error - -func validateBackend(input string) error { - var isHost bool - if _, err := net.LookupHost(input); err == nil { - isHost = true - } - var isAddr bool - if _, err := net.LookupAddr(input); err == nil { - isHost = true - } - isEmpty := input == "" - isOriginless := strings.ToLower(input) == "originless" - if !isEmpty && !isOriginless && !isHost && !isAddr { - return fmt.Errorf(`must be "originless" or a valid hostname, IPv4, or IPv6 address`) - } - return nil -} - -func validateDomain(input string) error { - if input == "" { - return nil - } - if !domainNameRegEx.MatchString(input) { - return fmt.Errorf("must be valid domain name") - } - return nil -} diff --git a/pkg/compute/doc.go b/pkg/compute/doc.go deleted file mode 100644 index 19ca8b297..000000000 --- a/pkg/compute/doc.go +++ /dev/null @@ -1,2 +0,0 @@ -// Package compute contains commands to manage Compute@Edge packages. -package compute diff --git a/pkg/compute/init.go b/pkg/compute/init.go deleted file mode 100644 index 4047d363f..000000000 --- a/pkg/compute/init.go +++ /dev/null @@ -1,583 +0,0 @@ -package compute - -import ( - "crypto/rand" - "fmt" - "io" - "os" - "path/filepath" - "regexp" - "strconv" - "strings" - "time" - - "github.com/fastly/cli/pkg/api" - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/filesystem" - "github.com/fastly/cli/pkg/text" - - "gopkg.in/src-d/go-git.v4" - "gopkg.in/src-d/go-git.v4/plumbing" -) - -var ( - gitRepositoryRegEx = regexp.MustCompile(`((git|ssh|http(s)?)|(git@[\w\.]+))(:(//)?)([\w\.@\:/\-~]+)(\.git)(/)?`) - domainNameRegEx = regexp.MustCompile(`(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]`) - fastlyOrgRegEx = regexp.MustCompile(`^https:\/\/github\.com\/fastly`) - fastlyFileIgnoreListRegEx = regexp.MustCompile(`\.github|LICENSE|SECURITY\.md|CHANGELOG\.md|screenshot\.png`) -) - -// InitCommand initializes a Compute@Edge project package on the local machine. -type InitCommand struct { - common.Base - client api.HTTPClient - manifest manifest.Data - language string - from string - branch string - tag string - path string - forceNonEmpty bool -} - -// NewInitCommand returns a usable command registered under the parent. -func NewInitCommand(parent common.Registerer, client api.HTTPClient, globals *config.Data) *InitCommand { - var c InitCommand - c.Globals = globals - c.client = client - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("init", "Initialize a new Compute@Edge package locally") - c.CmdClause.Flag("name", "Name of package, defaulting to directory name of the --path destination").Short('n').StringVar(&c.manifest.File.Name) - c.CmdClause.Flag("description", "Description of the package").Short('d').StringVar(&c.manifest.File.Description) - c.CmdClause.Flag("author", "Author(s) of the package").Short('a').StringsVar(&c.manifest.File.Authors) - c.CmdClause.Flag("language", "Language of the package").Short('l').StringVar(&c.language) - c.CmdClause.Flag("from", "Git repository containing package template").Short('f').StringVar(&c.from) - c.CmdClause.Flag("branch", "Git branch name to clone from package template repository").Hidden().StringVar(&c.branch) - c.CmdClause.Flag("tag", "Git tag name to clone from package template repository").Hidden().StringVar(&c.tag) - c.CmdClause.Flag("path", "Destination to write the new package, defaulting to the current directory").Short('p').StringVar(&c.path) - c.CmdClause.Flag("force", "Skip non-empty directory verification step and force new project creation").BoolVar(&c.forceNonEmpty) - - return &c -} - -// Exec implements the command interface. -func (c *InitCommand) Exec(in io.Reader, out io.Writer) (err error) { - text.Output(out, "Creating a new Compute@Edge project.") - text.Break(out) - text.Output(out, "Press ^C at any time to quit.") - text.Break(out) - - if !c.forceNonEmpty { - cont, err := verifyDirectory(out, in) - if err != nil { - return err - } - if !cont { - return errors.RemediationError{ - Inner: fmt.Errorf("project directory not empty"), - Remediation: errors.ExistingDirRemediation, - } - } - } - - var progress text.Progress - if c.Globals.Verbose() { - progress = text.NewVerboseProgress(out) - } else { - // Use a null progress writer whilst gathering input. - progress = text.NewNullProgress() - } - defer func() { - if err != nil { - progress.Fail() // progress.Done is handled inline - } - }() - - var ( - name string - desc string - authors []string - language *Language - from string - ) - - languages := []*Language{ - NewLanguage(&LanguageOptions{ - Name: "rust", - DisplayName: "Rust", - StarterKits: c.Globals.File.StarterKits.Rust, - Toolchain: NewRust(c.client, c.Globals), - }), - NewLanguage(&LanguageOptions{ - Name: "assemblyscript", - DisplayName: "AssemblyScript (beta)", - StarterKits: c.Globals.File.StarterKits.AssemblyScript, - Toolchain: NewAssemblyScript(), - }), - } - - if c.path == "" && !c.manifest.File.Exists() { - fmt.Fprintf(progress, "--path not specified, using current directory\n") - path, err := os.Getwd() - if err != nil { - return fmt.Errorf("error determining current directory: %w", err) - } - c.path = path - } - - abspath, err := verifyDestination(c.path, progress) - if err != nil { - return err - } - c.path = abspath - - name, _ = c.manifest.Name() - name, err = pkgName(name, c.path, in, out) - if err != nil { - return err - } - - desc, _ = c.manifest.Description() - desc, err = pkgDesc(desc, in, out) - if err != nil { - return err - } - - authors, _ = c.manifest.Authors() - authors, err = pkgAuthors(authors, c.Globals.File.User.Email, in, out) - if err != nil { - return err - } - - language, err = pkgLang(c.language, languages, in, out) - if err != nil { - return err - } - - manifestExist := c.manifest.File.Exists() - - from, branch, tag, err := pkgFrom(c.from, c.branch, c.tag, manifestExist, language.StarterKits, in, out) - if err != nil { - return err - } - - text.Break(out) - - if !c.Globals.Verbose() { - progress = text.NewQuietProgress(out) - } - - if from != "" && !manifestExist { - err := pkgFetch(from, branch, tag, c.path, progress) - if err != nil { - return err - } - } - - m, err := updateManifest(c.manifest.File, progress, c.path, name, desc, authors, language) - if err != nil { - return err - } - - progress.Step("Initializing package...") - - if err := language.Initialize(progress); err != nil { - fmt.Println(err) - return fmt.Errorf("error initializing package: %w", err) - } - - progress.Done() - - text.Break(out) - - text.Description(out, fmt.Sprintf("Initialized package %s to", text.Bold(m.Name)), abspath) - text.Description(out, "To publish the package (build and deploy), run", "fastly compute publish") - text.Description(out, "To learn about deploying Compute@Edge projects using third-party orchestration tools, visit", "https://developer.fastly.com/learning/integrations/orchestration/") - text.Success(out, "Initialized package %s", text.Bold(m.Name)) - - return nil -} - -// pkgName prompts the user for a package name unless already defined either -// via the corresponding CLI flag or the manifest file. -// -// It will use a default of the current directory path if no value provided by -// the user via the prompt. -func pkgName(name string, dirPath string, in io.Reader, out io.Writer) (string, error) { - defaultName := filepath.Base(dirPath) - - if name == "" { - var err error - - name, err = text.Input(out, fmt.Sprintf("Name: [%s] ", defaultName), in) - if err != nil { - return "", fmt.Errorf("error reading input: %w", err) - } - - if name == "" { - name = defaultName - } - } - - return name, nil -} - -// pkgDesc prompts the user for a package description unless already defined -// either via the corresponding CLI flag or the manifest file. -func pkgDesc(desc string, in io.Reader, out io.Writer) (string, error) { - if desc == "" { - var err error - - desc, err = text.Input(out, "Description: ", in) - if err != nil { - return "", fmt.Errorf("error reading input: %w", err) - } - } - - return desc, nil -} - -// pkgAuthors prompts the user for a package name unless already defined either -// via the corresponding CLI flag or the manifest file. -// -// It will use a default of the user's email found within the manifest, if set -// there, otherwise the value will be an empty slice. -func pkgAuthors(authors []string, manifestEmail string, in io.Reader, out io.Writer) ([]string, error) { - if len(authors) == 0 { - label := "Author: " - - if manifestEmail != "" { - label = fmt.Sprintf("%s[%s] ", label, manifestEmail) - } - - author, err := text.Input(out, label, in) - if err != nil { - return []string{}, fmt.Errorf("error reading input %w", err) - } - - if author != "" { - authors = []string{author} - } else { - authors = []string{manifestEmail} - } - } - - return authors, nil -} - -// pkgLang prompts the user for a package language unless already defined -// either via the corresponding CLI flag or the manifest file. -func pkgLang(lang string, languages []*Language, in io.Reader, out io.Writer) (*Language, error) { - var language *Language - - if lang == "" { - text.Output(out, "%s", text.Bold("Language:")) - for i, lang := range languages { - text.Output(out, "[%d] %s", i+1, lang.DisplayName) - } - option, err := text.Input(out, "Choose option: [1] ", in, validateLanguageOption(languages)) - if err != nil { - return nil, fmt.Errorf("reading input %w", err) - } - if option == "" { - option = "1" - } - if i, err := strconv.Atoi(option); err == nil { - language = languages[i-1] - } else { - return nil, fmt.Errorf("selecting language") - } - } else { - for _, l := range languages { - if strings.EqualFold(lang, l.Name) { - language = l - } - } - } - - return language, nil -} - -// pkgFrom prompts the user for a package starter kit unless already defined -// either via the corresponding CLI flag or the manifest file. -// -// It returns the path to the starter kit, and the corresponding branch/tag, -// otherwise if there' is an error converting the prompt input, then the option -// number is returned along with the branch/tag that was potentially provided -// via the corresponding CLI flag or manifest content. -func pkgFrom(from string, branch string, tag string, manifestExist bool, kits []config.StarterKit, in io.Reader, out io.Writer) (string, string, string, error) { - if from == "" && !manifestExist { - text.Output(out, "%s", text.Bold("Starter kit:")) - for i, kit := range kits { - text.Output(out, "[%d] %s (%s)", i+1, kit.Name, kit.Path) - } - option, err := text.Input(out, "Choose option or type URL: [1] ", in, validateTemplateOptionOrURL(kits)) - if err != nil { - return "", "", "", fmt.Errorf("error reading input %w", err) - } - if option == "" { - option = "1" - } - - if i, err := strconv.Atoi(option); err == nil { - template := kits[i-1] - from = template.Path - branch = template.Branch - tag = template.Tag - } else { - from = option - } - } - - return from, branch, tag, nil -} - -// pkgFetch clones the given repo (from) into a temp directory, then copies -// specific files to the destination directory (path). -func pkgFetch(from string, branch string, tag string, fpath string, progress text.Progress) error { - progress.Step("Fetching package template...") - - tempdir, err := tempDir("package-init") - if err != nil { - return fmt.Errorf("error creating temporary path for package template: %w", err) - } - defer os.RemoveAll(tempdir) - - if branch != "" && tag != "" { - return fmt.Errorf("cannot use both git branch and tag name") - } - - var ref plumbing.ReferenceName - - if branch != "" { - ref = plumbing.NewBranchReferenceName(branch) - } - - if tag != "" { - ref = plumbing.NewTagReferenceName(tag) - } - - _, err = git.PlainClone(tempdir, false, &git.CloneOptions{ - URL: from, - ReferenceName: ref, - Depth: 1, - Progress: progress, - }) - if err != nil { - return fmt.Errorf("error fetching package template: %w", err) - } - - if err := os.RemoveAll(filepath.Join(tempdir, ".git")); err != nil { - return fmt.Errorf("error removing git metadata from package template: %w", err) - } - - err = filepath.Walk(tempdir, func(path string, info os.FileInfo, err error) error { - if err != nil { - return err // abort - } - - if info.IsDir() { - return nil // descend - } - - rel, err := filepath.Rel(tempdir, path) - if err != nil { - return err - } - - // Filter any files we want to ignore in Fastly-owned templates. - if fastlyOrgRegEx.MatchString(from) && fastlyFileIgnoreListRegEx.MatchString(rel) { - return nil - } - - dst := filepath.Join(fpath, rel) - if err := os.MkdirAll(filepath.Dir(dst), 0750); err != nil { - return err - } - - if err := filesystem.CopyFile(path, dst); err != nil { - return err - } - - return nil - }) - - if err != nil { - return fmt.Errorf("error copying files from package template: %w", err) - } - - return nil -} - -// updateManifest updates the manifest with data acquired from various sources. -// e.g. prompting the user, existing manifest file. -func updateManifest(m manifest.File, progress text.Progress, path string, name string, desc string, authors []string, lang *Language) (manifest.File, error) { - progress.Step("Updating package manifest...") - - mp := filepath.Join(path, ManifestFilename) - - if err := m.Read(mp); err != nil { - return m, fmt.Errorf("error reading package manifest: %w", err) - } - - fmt.Fprintf(progress, "Setting package name in manifest to %q...\n", name) - m.Name = name - - if desc != "" { - fmt.Fprintf(progress, "Setting description in manifest to %s...\n", desc) - m.Description = desc - } - - if len(authors) > 0 { - fmt.Fprintf(progress, "Setting authors in manifest to %s...\n", strings.Join(authors, ", ")) - m.Authors = authors - } - - fmt.Fprintf(progress, "Setting language in manifest to %s...\n", lang.Name) - m.Language = lang.Name - - if err := m.Write(mp); err != nil { - return m, fmt.Errorf("error saving package manifest: %w", err) - } - - return m, nil -} - -// verifyDirectory indicates if the user wants to continue with the execution -// flow when presented with a prompt that suggests the current directory isn't -// empty. -func verifyDirectory(out io.Writer, in io.Reader) (bool, error) { - files, err := os.ReadDir(".") - if err != nil { - return false, err - } - - if len(files) > 0 { - dir, err := os.Getwd() - if err != nil { - return false, err - } - - label := fmt.Sprintf("The current directory isn't empty. Are you sure you want to initialize a Compute@Edge project in %s? [y/n] ", dir) - cont, err := text.Input(out, label, in) - if err != nil { - return false, fmt.Errorf("error reading input %w", err) - } - - contl := strings.ToLower(cont) - - if contl == "n" || contl == "no" { - return false, nil - } - - if contl == "y" || contl == "yes" { - return true, nil - } - - // NOTE: be defensive and default to short-circuiting the execution flow if - // the input is unrecognised. - return false, nil - } - - return true, nil -} - -func verifyDestination(path string, verbose io.Writer) (abspath string, err error) { - abspath, err = filepath.Abs(path) - if err != nil { - return abspath, err - } - - fi, err := os.Stat(abspath) - if err != nil && !os.IsNotExist(err) { - return abspath, fmt.Errorf("couldn't verify package directory: %w", err) // generic error - } - if err == nil && !fi.IsDir() { - return abspath, fmt.Errorf("package destination is not a directory") // specific problem - } - if err != nil && os.IsNotExist(err) { // normal-ish case - fmt.Fprintf(verbose, "Creating %s...\n", abspath) - if err := os.MkdirAll(abspath, 0700); err != nil { - return abspath, fmt.Errorf("error creating package destination: %w", err) - } - } - - tmpname := make([]byte, 16) - n, err := rand.Read(tmpname) - if err != nil { - return abspath, fmt.Errorf("error generating random filename: %w", err) - } - if n != 16 { - return abspath, fmt.Errorf("failed to generate enough entropy (%d/%d)", n, 16) - } - - f, err := os.Create(filepath.Join(abspath, fmt.Sprintf("tmp_%x", tmpname))) - if err != nil { - return abspath, fmt.Errorf("error creating file in package destination: %w", err) - } - - if err := f.Close(); err != nil { - return abspath, fmt.Errorf("error closing file in package destination: %w", err) - } - - if err := os.Remove(f.Name()); err != nil { - return abspath, fmt.Errorf("error removing file in package destination: %w", err) - } - - return abspath, nil -} - -func tempDir(prefix string) (abspath string, err error) { - abspath, err = filepath.Abs(filepath.Join( - os.TempDir(), - fmt.Sprintf("%s-%d", prefix, time.Now().UnixNano()), - )) - if err != nil { - return "", err - } - - if err = os.MkdirAll(abspath, 0750); err != nil { - return "", err - } - - return abspath, nil -} - -func validateLanguageOption(languages []*Language) func(string) error { - return func(input string) error { - errMsg := fmt.Errorf("must be a valid option") - if input == "" { - return nil - } - if option, err := strconv.Atoi(input); err == nil { - if option > len(languages) { - return errMsg - } - return nil - } - return errMsg - } -} - -func validateTemplateOptionOrURL(templates []config.StarterKit) func(string) error { - return func(input string) error { - msg := "must be a valid option or Git URL" - if input == "" { - return nil - } - if option, err := strconv.Atoi(input); err == nil { - if option > len(templates) { - return fmt.Errorf(msg) - } - return nil - } - if !gitRepositoryRegEx.MatchString(input) { - return fmt.Errorf(msg) - } - return nil - } -} diff --git a/pkg/compute/manifest/manifest.go b/pkg/compute/manifest/manifest.go deleted file mode 100644 index c4968cb18..000000000 --- a/pkg/compute/manifest/manifest.go +++ /dev/null @@ -1,408 +0,0 @@ -package manifest - -import ( - "bufio" - "bytes" - "fmt" - "io" - "os" - "strconv" - "strings" - "sync" - - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - toml "github.com/pelletier/go-toml" -) - -// Source enumerates where a manifest parameter is taken from. -type Source uint8 - -const ( - // Filename is the name of the package manifest file. - // It is expected to be a project specific configuration file. - Filename = "fastly.toml" - - // ManifestLatestVersion represents the latest known manifest schema version - // supported by the CLI. - ManifestLatestVersion = 1 - - // FilePermissions represents a read/write file mode. - FilePermissions = 0666 - - // SourceUndefined indicates the parameter isn't provided in any of the - // available sources, similar to "not found". - SourceUndefined Source = iota - - // SourceFile indicates the parameter came from a manifest file. - SourceFile - - // SourceFlag indicates the parameter came from an explicit flag. - SourceFlag - - // SpecIntro informs the user of what the manifest file is for. - SpecIntro = "This file describes a Fastly Compute@Edge package. To learn more visit:" - - // SpecURL points to the fastly.toml manifest specification reference. - SpecURL = "https://developer.fastly.com/reference/fastly-toml/" -) - -var once sync.Once - -// Data holds global-ish manifest data from manifest files, and flag sources. -// It has methods to give each parameter to the components that need it, -// including the place the parameter came from, which is a requirement. -// -// If the same parameter is defined in multiple places, it is resolved according -// to the following priority order: the manifest file (lowest priority) and then -// explicit flags (highest priority). -type Data struct { - File File - Flag Flag -} - -// Name yields a Name. -func (d *Data) Name() (string, Source) { - if d.Flag.Name != "" { - return d.Flag.Name, SourceFlag - } - - if d.File.Name != "" { - return d.File.Name, SourceFile - } - - return "", SourceUndefined -} - -// ServiceID yields a ServiceID. -func (d *Data) ServiceID() (string, Source) { - if d.Flag.ServiceID != "" { - return d.Flag.ServiceID, SourceFlag - } - - if d.File.ServiceID != "" { - return d.File.ServiceID, SourceFile - } - - return "", SourceUndefined -} - -// Description yields a Description. -func (d *Data) Description() (string, Source) { - if d.Flag.Description != "" { - return d.Flag.Description, SourceFlag - } - - if d.File.Description != "" { - return d.File.Description, SourceFile - } - - return "", SourceUndefined -} - -// Authors yields an Authors. -func (d *Data) Authors() ([]string, Source) { - if len(d.Flag.Authors) > 0 { - return d.Flag.Authors, SourceFlag - } - - if len(d.File.Authors) > 0 { - return d.File.Authors, SourceFile - } - - return []string{}, SourceUndefined -} - -// Version represents the currently supported schema for the fastly.toml -// manifest file that determines the configuration for a compute@edge service. -// -// NOTE: the File object has a field called ManifestVersion which this type is -// assigned. The reason we don't name this type ManifestVersion is to appease -// the static analysis linter which complains re: stutter in the import -// manifest.ManifestVersion. -type Version int - -// UnmarshalText manages multiple scenarios where historically the manifest -// version was a string value and not an integer. -// -// Example mappings: -// -// "0.1.0" -> 1 -// "1" -> 1 -// 1 -> 1 -// "1.0.0" -> 1 -// 0.1 -> 1 -// "0.2.0" -> 1 -// "2.0.0" -> 2 -// -// We also constrain the version so that if a user has a manifest_version -// defined as "99.0.0" then we won't accidentally store it as the integer 99 -// but instead will return an error because it exceeds the current -// ManifestLatestVersion version of 1. -func (v *Version) UnmarshalText(text []byte) error { - s := string(text) - - if i, err := strconv.Atoi(s); err == nil { - *v = Version(i) - return nil - } - - if f, err := strconv.ParseFloat(s, 32); err == nil { - intfl := int(f) - if intfl == 0 { - *v = 1 - } else { - *v = Version(intfl) - } - return nil - } - - if strings.Contains(s, ".") { - segs := strings.Split(s, ".") - - // A length of 3 presumes a semver (e.g. 0.1.0) - if len(segs) == 3 { - if segs[0] != "0" { - if i, err := strconv.Atoi(segs[0]); err == nil { - if i > ManifestLatestVersion { - return errors.ErrUnrecognisedManifestVersion - } - *v = Version(i) - return nil - } - } else { - *v = 1 - return nil - } - } - } - - return errors.ErrUnrecognisedManifestVersion -} - -// File represents all of the configuration parameters in the fastly.toml -// manifest file schema. -type File struct { - ManifestVersion Version `toml:"manifest_version"` - Name string `toml:"name"` - Description string `toml:"description"` - Authors []string `toml:"authors"` - Language string `toml:"language"` - ServiceID string `toml:"service_id"` - - exists bool - output io.Writer -} - -// Exists yields whether the manifest exists. -func (f *File) Exists() bool { - return f.exists -} - -// SetOutput sets the output stream for any messages. -func (f *File) SetOutput(output io.Writer) { - f.output = output -} - -// Read loads the manifest file content from disk. -func (f *File) Read(fpath string) error { - // gosec flagged this: - // G304 (CWE-22): Potential file inclusion via variable. - // Disabling as we need to load the fastly.toml from the user's file system. - // This file is decoded into a predefined struct, any unrecognised fields are dropped. - /* #nosec */ - bs, err := os.ReadFile(fpath) - if err != nil { - return err - } - - // NOTE: temporary fix needed because of a bug that appeared in v0.25.0 where - // the manifest_version was stored in fastly.toml as a 'section', e.g. - // `[manifest_version]`. - // - // This subsequently would cause errors when trying to unmarshal the data, so - // we need to identify if it exists in the file (as a section) and remove it. - // - // We do this before trying to unmarshal the toml data into a go data - // structure otherwise we'll see errors from the toml library. - manifestSection, err := containsManifestSection(bs) - if err != nil { - return fmt.Errorf("failed to parse the fastly.toml manifest: %w", err) - } - - if manifestSection { - buf, err := stripManifestSection(bytes.NewReader(bs), fpath) - if err != nil { - return errors.ErrInvalidManifestVersion - } - bs = buf.Bytes() - } - - err = toml.Unmarshal(bs, f) - if err != nil { - return err - } - - f.exists = true - - if f.ManifestVersion == 0 { - f.ManifestVersion = 1 - - // NOTE: the use of once is a quick-fix to side-step duplicate outputs. - // To fix this properly will require a refactor of the structure of how our - // global output is passed around. - once.Do(func() { - text.Warning(f.output, "The fastly.toml was missing a `manifest_version` field. A default schema version of `1` will be used.") - text.Break(f.output) - text.Output(f.output, fmt.Sprintf("Refer to the fastly.toml package manifest format: %s", SpecURL)) - text.Break(f.output) - f.Write(fpath) - }) - } - - return nil -} - -// containsManifestSection loads the slice of bytes into a toml tree structure -// before checking if the manifest_version is defined as a toml section block. -func containsManifestSection(bs []byte) (bool, error) { - tree, err := toml.LoadBytes(bs) - if err != nil { - return false, err - } - - if _, ok := tree.GetArray("manifest_version").(*toml.Tree); ok { - return true, nil - } - - return false, nil -} - -// stripManifestSection reads the manifest line-by-line storing the lines that -// don't contain `[manifest_version]` into a buffer to be written back to disk. -// -// It would've been better if we could have relied on the toml library to delete -// the section but unfortunately that means it would end up deleting the entire -// block and not just the key specified. Meaning if the manifest_version key -// was in the middle of the manifest with other keys below it, deleting the -// manifest_version would cause all keys below it to be deleted as they would -// all be considered part of that section block. -func stripManifestSection(r io.Reader, fpath string) (*bytes.Buffer, error) { - var bs []byte - buf := bytes.NewBuffer(bs) - - scanner := bufio.NewScanner(r) - for scanner.Scan() { - if scanner.Text() != "[manifest_version]" { - _, err := buf.Write(scanner.Bytes()) - if err != nil { - return buf, err - } - _, err = buf.WriteString("\n") - if err != nil { - return buf, err - } - } - } - if err := scanner.Err(); err != nil { - return buf, err - } - - err := os.WriteFile(fpath, buf.Bytes(), FilePermissions) - if err != nil { - return buf, err - } - - return buf, nil -} - -// Write persists the manifest content to disk. -func (f *File) Write(filename string) error { - fp, err := os.Create(filename) - if err != nil { - return err - } - - if err := toml.NewEncoder(fp).Encode(f); err != nil { - return err - } - - if err := prependSpecRefToManifest(fp); err != nil { - return err - } - - if err := fp.Sync(); err != nil { - return err - } - - if err := fp.Close(); err != nil { - return err - } - - return nil -} - -// prependSpecRefToManifest checks if the manifest contains a reference to the -// manifest specification and, if not, prepends it to the top of the file. -// -// NOTE: We want to prepend a link to the fastly.toml reference but we also -// don't want to break the user experience if we have a problem (as writing -// this reference isn't critical to the operations the user is carrying out), -// so we don't handle the returned error but instead only proceed to attempt -// a WRITE to the file when there was no error seeking the start of the file. -// -// We have to seek to the start of the file so we can read back the contents, -// then once we have the contents stored we have to seek back to the start a -// second time so we can inject our reference link and start writing back out -// the toml contents. -// -// Although we don't want to error when attempting to seek the file, we do -// want to return an error if there was a problem writing the content to the -// buffer or if there was a problem flushing the buffer back to the io.Writer -// because this would indicate a problem with us getting back all of the -// original content. -func prependSpecRefToManifest(fp io.ReadWriteSeeker) error { - if _, err := fp.Seek(0, 0); err == nil { - content := make([]string, 0) - scanner := bufio.NewScanner(fp) - - for scanner.Scan() { - content = append(content, scanner.Text()) - } - - if err := scanner.Err(); err != nil { - return err - } - - if content[0] != SpecIntro || content[1] != SpecURL { - if _, err := fp.Seek(0, 0); err == nil { - writer := bufio.NewWriter(fp) - writer.WriteString(fmt.Sprintf("# %s\n# %s\n\n", SpecIntro, SpecURL)) - - for _, line := range content { - _, err := writer.WriteString(line + "\n") - if err != nil { - return err - } - } - - if err := writer.Flush(); err != nil { - return err - } - } - } - } - - fp.Seek(0, 0) - - return nil -} - -// Flag represents all of the manifest parameters that can be set with explicit -// flags. Consumers should bind their flag values to these fields directly. -type Flag struct { - Name string - Description string - Authors []string - ServiceID string -} diff --git a/pkg/compute/manifest/manifest_test.go b/pkg/compute/manifest/manifest_test.go deleted file mode 100644 index 50a77baa5..000000000 --- a/pkg/compute/manifest/manifest_test.go +++ /dev/null @@ -1,161 +0,0 @@ -package manifest - -import ( - "errors" - "io" - "os" - "path/filepath" - "strings" - "testing" - - errs "github.com/fastly/cli/pkg/errors" -) - -func TestManifest(t *testing.T) { - prefix := filepath.Join("../", "testdata", "init") - - tests := map[string]struct { - manifest string - valid bool - expectedError error - }{ - "valid: semver": { - manifest: "fastly-valid-semver.toml", - valid: true, - }, - "valid: integer": { - manifest: "fastly-valid-integer.toml", - valid: true, - }, - "invalid: missing manifest_version causes default to be set": { - manifest: "fastly-invalid-missing-version.toml", - valid: true, - }, - "invalid: manifest_version as a section causes default to be set": { - manifest: "fastly-invalid-section-version.toml", - valid: true, - }, - "invalid: manifest_version Atoi error": { - manifest: "fastly-invalid-unrecognised.toml", - valid: false, - expectedError: errs.ErrUnrecognisedManifestVersion, - }, - "unrecognised: manifest_version exceeded limit": { - manifest: "fastly-invalid-version-exceeded.toml", - valid: false, - expectedError: errs.ErrUnrecognisedManifestVersion, - }, - } - - // NOTE: the fixture files "fastly-invalid-missing-version.toml" and - // "fastly-invalid-section-version.toml" will be overwritten by the test as - // the internal logic is supposed to add back into the manifest a - // manifest_version field if one isn't found (or is invalid). - // - // To ensure future test runs complete successfully we do an initial read of - // the data and then write it back out when the tests have completed. - - for _, fpath := range []string{ - "fastly-invalid-missing-version.toml", - "fastly-invalid-section-version.toml", - } { - path, err := filepath.Abs(filepath.Join(prefix, fpath)) - if err != nil { - t.Fatal(err) - } - - b, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - - defer func(path string, b []byte) { - err := os.WriteFile(path, b, 0644) - if err != nil { - t.Fatal(err) - } - }(path, b) - } - - for name, tc := range tests { - t.Run(name, func(t *testing.T) { - var m File - m.SetOutput(os.Stdout) - - path, err := filepath.Abs(filepath.Join(prefix, tc.manifest)) - if err != nil { - t.Fatal(err) - } - - err = m.Read(path) - if tc.valid { - // if we expect the manifest to be valid and we get an error, then - // that's unexpected behaviour. - if err != nil { - t.Fatal(err) - } - } else { - // otherwise if we expect the manifest to be invalid/unrecognised then - // the error should match our expectations. - if !errors.As(err, &tc.expectedError) { - t.Fatalf("incorrect error type: %T, expected: %T", err, tc.expectedError) - } - } - }) - } -} - -func TestManifestPrepend(t *testing.T) { - prefix := filepath.Join("../", "testdata", "init") - - // NOTE: the fixture file "fastly-missing-spec-url.toml" will be - // overwritten by the test as the internal logic is supposed to add into the - // manifest a reference to the fastly.toml specification. - // - // To ensure future test runs complete successfully we do an initial read of - // the data and then write it back out when the tests have completed. - - fpath := "fastly-missing-spec-url.toml" - - path, err := filepath.Abs(filepath.Join(prefix, fpath)) - if err != nil { - t.Fatal(err) - } - - b, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - - defer func(path string, b []byte) { - err := os.WriteFile(path, b, 0644) - if err != nil { - t.Fatal(err) - } - }(path, b) - - f, err := os.OpenFile(path, os.O_RDWR, 0644) - if err != nil { - t.Fatal(err) - } - - err = prependSpecRefToManifest(f) - if err != nil { - t.Fatal(err) - } - - bs, err := io.ReadAll(f) - if err != nil { - t.Fatal(err) - } - - content := string(bs) - - if !strings.Contains(content, SpecIntro) || !strings.Contains(content, SpecURL) { - t.Fatal("missing fastly.toml specification reference link") - } - - if err = f.Close(); err != nil { - t.Fatal(err) - } -} diff --git a/pkg/compute/publish.go b/pkg/compute/publish.go deleted file mode 100644 index b39694bd2..000000000 --- a/pkg/compute/publish.go +++ /dev/null @@ -1,112 +0,0 @@ -package compute - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/text" -) - -// PublishCommand produces and deploys an artifact from files on the local disk. -type PublishCommand struct { - common.Base - manifest manifest.Data - build *BuildCommand - deploy *DeployCommand - - // Deploy fields - path common.OptionalString - version common.OptionalInt - domain common.OptionalString - backend common.OptionalString - backendPort common.OptionalUint - - // Build fields - name common.OptionalString - lang common.OptionalString - includeSrc common.OptionalBool - force common.OptionalBool -} - -// NewPublishCommand returns a usable command registered under the parent. -func NewPublishCommand(parent common.Registerer, globals *config.Data, build *BuildCommand, deploy *DeployCommand) *PublishCommand { - var c PublishCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.build = build - c.deploy = deploy - c.CmdClause = parent.Command("publish", "Build and deploy a Compute@Edge package to a Fastly service") - - // Build flags - c.CmdClause.Flag("name", "Package name").Action(c.name.Set).StringVar(&c.name.Value) - c.CmdClause.Flag("language", "Language type").Action(c.lang.Set).StringVar(&c.lang.Value) - c.CmdClause.Flag("include-source", "Include source code in built package").Action(c.includeSrc.Set).BoolVar(&c.includeSrc.Value) - c.CmdClause.Flag("force", "Skip verification steps and force build").Action(c.force.Set).BoolVar(&c.force.Value) - - // Deploy flags - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("version", "Number of version to activate").Action(c.version.Set).IntVar(&c.version.Value) - c.CmdClause.Flag("path", "Path to package").Short('p').Action(c.path.Set).StringVar(&c.path.Value) - c.CmdClause.Flag("domain", "The name of the domain associated to the package").Action(c.domain.Set).StringVar(&c.domain.Value) - c.CmdClause.Flag("backend", "A hostname, IPv4, or IPv6 address for the package backend").Action(c.backend.Set).StringVar(&c.backend.Value) - c.CmdClause.Flag("backend-port", "A port number for the package backend").Action(c.backendPort.Set).UintVar(&c.backendPort.Value) - - return &c -} - -// Exec implements the command interface. -// -// NOTE: unlike other non-aggregate commands that initialize a new -// text.Progress type for displaying progress information to the user, we don't -// use that in this command because the nested commands overlap the output in -// non-deterministic ways. It's best to leave those nested commands to handle -// the progress indicator. -func (c *PublishCommand) Exec(in io.Reader, out io.Writer) (err error) { - // Reset the fields on the BuildCommand based on PublishCommand values. - if c.name.WasSet { - c.build.PackageName = c.name.Value - } - if c.lang.WasSet { - c.build.Lang = c.lang.Value - } - if c.includeSrc.WasSet { - c.build.IncludeSrc = c.includeSrc.Value - } - if c.force.WasSet { - c.build.Force = c.force.Value - } - - err = c.build.Exec(in, out) - if err != nil { - return err - } - - text.Break(out) - - // Reset the fields on the DeployCommand based on PublishCommand values. - if c.path.WasSet { - c.deploy.Path = c.path.Value - } - if c.version.WasSet { - c.deploy.Version = c.version // deploy's field is a common.OptionalInt - } - if c.domain.WasSet { - c.deploy.Domain = c.domain.Value - } - if c.backend.WasSet { - c.deploy.Backend = c.backend.Value - } - if c.backendPort.WasSet { - c.deploy.BackendPort = c.backendPort.Value - } - - err = c.deploy.Exec(in, out) - if err != nil { - return err - } - - return nil -} diff --git a/pkg/compute/root.go b/pkg/compute/root.go deleted file mode 100644 index 12f407b72..000000000 --- a/pkg/compute/root.go +++ /dev/null @@ -1,31 +0,0 @@ -package compute - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/config" -) - -// ManifestFilename is the name of the package manifest file. -const ManifestFilename = "fastly.toml" - -// RootCommand is the parent command for all subcommands in this package. -// It should be installed under the primary root command. -type RootCommand struct { - common.Base - // no flags -} - -// NewRootCommand returns a new command registered in the parent. -func NewRootCommand(parent common.Registerer, globals *config.Data) *RootCommand { - var c RootCommand - c.Globals = globals - c.CmdClause = parent.Command("compute", "Manage Compute@Edge packages") - return &c -} - -// Exec implements the command interface. -func (c *RootCommand) Exec(in io.Reader, out io.Writer) error { - panic("unreachable") -} diff --git a/pkg/compute/rust.go b/pkg/compute/rust.go deleted file mode 100644 index a4a091eaf..000000000 --- a/pkg/compute/rust.go +++ /dev/null @@ -1,488 +0,0 @@ -package compute - -import ( - "bufio" - "bytes" - "encoding/json" - "fmt" - "io" - "net/http" - "os" - "os/exec" - "path/filepath" - "sort" - "strings" - - "github.com/Masterminds/semver/v3" - "github.com/fastly/cli/pkg/api" - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/filesystem" - "github.com/fastly/cli/pkg/text" - toml "github.com/pelletier/go-toml" -) - -// CargoPackage models the package configuration properties of a Rust Cargo -// package which we are interested in and is embedded within CargoManifest and -// CargoLock. -type CargoPackage struct { - Name string `toml:"name" json:"name"` - Version string `toml:"version" json:"version"` - Dependencies []CargoPackage `toml:"-" json:"dependencies"` -} - -// CargoManifest models the package configuration properties of a Rust Cargo -// manifest which we are interested in and are read from the Cargo.toml manifest -// file within the $PWD of the package. -type CargoManifest struct { - Package CargoPackage -} - -// Read the contents of the Cargo.toml manifest from filename. -func (m *CargoManifest) Read(fpath string) error { - // gosec flagged this: - // G304 (CWE-22): Potential file inclusion via variable. - // Disabling as we need to load the Cargo.toml from the user's file system. - // This file is decoded into a predefined struct, any unrecognised fields are dropped. - /* #nosec */ - bs, err := os.ReadFile(fpath) - if err != nil { - return err - } - err = toml.Unmarshal(bs, m) - return err -} - -// CargoMetadata models information about the workspace members and resolved -// dependencies of the current package via `cargo metadata` command output. -type CargoMetadata struct { - Package []CargoPackage `json:"packages"` - TargetDirectory string `json:"target_directory"` -} - -// Read the contents of the Cargo.lock file from filename. -func (m *CargoMetadata) Read() error { - cmd := exec.Command("cargo", "metadata", "--quiet", "--format-version", "1") - stdout, err := cmd.StdoutPipe() - if err != nil { - return err - } - if err := cmd.Start(); err != nil { - return err - } - if err := json.NewDecoder(stdout).Decode(&m); err != nil { - return err - } - if err := cmd.Wait(); err != nil { - return err - } - return nil -} - -// Rust is an implements Toolchain for the Rust language. -type Rust struct { - client api.HTTPClient - config *config.Data -} - -// NewRust constructs a new Rust. -func NewRust(client api.HTTPClient, config *config.Data) *Rust { - return &Rust{ - client: client, - config: config, - } -} - -// SourceDirectory implements the Toolchain interface and returns the source -// directory for Rust packages. -func (r Rust) SourceDirectory() string { return "src" } - -// IncludeFiles implements the Toolchain interface and returns a list of -// additional files to include in the package archive for Rust packages. -func (r Rust) IncludeFiles() []string { return []string{"Cargo.toml"} } - -// Verify implments the Toolchain interface and verifies whether the Rust -// language toolchain is correctly configured on the host. -func (r Rust) Verify(out io.Writer) error { - // 1) Check `rustup` is on $PATH - // - // Rustup is Rust's toolchain installer and manager, it is needed to assert - // that the correct WASI WASM compiler target is installed correctly. We - // only check whether the binary exists on the users $PATH and error with - // installation help text. - - fmt.Fprintf(out, "Checking if rustup is installed...\n") - - p, err := exec.LookPath("rustup") - if err != nil { - return errors.RemediationError{ - Inner: fmt.Errorf("`rustup` not found in $PATH"), - Remediation: fmt.Sprintf("To fix this error, run the following command:\n\n\t$ %s", text.Bold("curl https://sh.rustup.rs -sSf | sh")), - } - } - - fmt.Fprintf(out, "Found rustup at %s\n", p) - - // 2) Check that the desired rustup version is installed - fmt.Fprintf(out, "Checking if rustup %s is installed...\n", r.config.File.Language.Rust.RustupConstraint) - - cmd := exec.Command("rustup", "--version") - stdoutStderr, err := cmd.CombinedOutput() - if err != nil { - return fmt.Errorf("error executing rustup: %w", err) - } - - reader := bufio.NewReader(bytes.NewReader(stdoutStderr)) - line, err := reader.ReadString('\n') - if err != nil { - return fmt.Errorf("error reading rustup output: %w", err) - } - parts := strings.Split(line, " ") - // Either `rustup ()` or `rustup ( )` - if len(parts) != 3 && len(parts) != 4 { - return fmt.Errorf("error reading rustup version") - } - rustupVersion, err := semver.NewVersion(parts[1]) - if err != nil { - return fmt.Errorf("error parsing rustup version: %w", err) - } - - rustupConstraint, err := semver.NewConstraint(r.config.File.Language.Rust.RustupConstraint) - if err != nil { - return fmt.Errorf("error parsing rustup constraint: %w", err) - } - if !rustupConstraint.Check(rustupVersion) { - return errors.RemediationError{ - Inner: fmt.Errorf("rustup constraint not met: %s", r.config.File.Language.Rust.RustupConstraint), - Remediation: fmt.Sprintf("To fix this error, run the following command:\n\n\t$ %s\n", text.Bold("rustup self update")), - } - } - - // 3) Check that the desired toolchain version is installed - // - // We use rustup to assert that the toolchain is installed by streaming the output of - // `rustup toolchain list` and looking for a toolchain whose prefix matches our desired - // version. - fmt.Fprintf(out, "Checking if Rust %s is installed...\n", r.config.File.Language.Rust.ToolchainVersion) - - cmd = exec.Command("rustup", "toolchain", "list") - stdoutStderr, err = cmd.CombinedOutput() - if err != nil { - return fmt.Errorf("error executing rustup: %w", err) - } - - scanner := bufio.NewScanner(strings.NewReader(string(stdoutStderr))) - scanner.Split(bufio.ScanLines) - var found bool - for scanner.Scan() { - if strings.HasPrefix(scanner.Text(), r.config.File.Language.Rust.ToolchainVersion) { - found = true - break - } - } - - if !found { - return errors.RemediationError{ - Inner: fmt.Errorf("rust toolchain %s not found", r.config.File.Language.Rust.ToolchainVersion), - Remediation: fmt.Sprintf("To fix this error, run the following command:\n\n\t$ %s\n", text.Bold("rustup toolchain install "+r.config.File.Language.Rust.ToolchainVersion)), - } - } - - // 4) Check `wasm32-wasi` target exists - // - // We use rustup to assert that the target is installed for our toolchain by streaming the - // output of `rustup target list` and looking for the the `wasm32-wasi` value. If not found, - // we error with help text suggesting how to install. - - fmt.Fprintf(out, "Checking if %s target is installed...\n", r.config.File.Language.Rust.WasmWasiTarget) - - // gosec flagged this: - // G204 (CWE-78): Subprocess launched with function call as argument or cmd arguments - // - // TODO: decide if this is safe or not. It should be as the only affected - // user should be the person making the local configuration change. - // - /* #nosec */ - cmd = exec.Command("rustup", "target", "list", "--installed", "--toolchain", r.config.File.Language.Rust.ToolchainVersion) - stdoutStderr, err = cmd.CombinedOutput() - if err != nil { - return fmt.Errorf("error executing rustup: %w", err) - } - - scanner = bufio.NewScanner(strings.NewReader(string(stdoutStderr))) - scanner.Split(bufio.ScanWords) - found = false - for scanner.Scan() { - if scanner.Text() == r.config.File.Language.Rust.WasmWasiTarget { - found = true - break - } - } - - if !found { - return errors.RemediationError{ - Inner: fmt.Errorf("rust target %s not found", r.config.File.Language.Rust.WasmWasiTarget), - Remediation: fmt.Sprintf("To fix this error, run the following command:\n\n\t$ %s\n", text.Bold(fmt.Sprintf("rustup target add %s --toolchain %s", r.config.File.Language.Rust.WasmWasiTarget, r.config.File.Language.Rust.ToolchainVersion))), - } - } - - fmt.Fprintf(out, "Found wasm32-wasi target\n") - - // 5) Check Cargo.toml file exists in $PWD - // - // A valid Cargo.toml file is needed for the `cargo build` compilation - // process. Therefore, we assert whether one exists in the current $PWD. - - fpath, err := filepath.Abs("Cargo.toml") - if err != nil { - return fmt.Errorf("error getting Cargo.toml path: %w", err) - } - - if !filesystem.FileExists(fpath) { - return fmt.Errorf("%s not found", fpath) - } - - fmt.Fprintf(out, "Found Cargo.toml at %s\n", fpath) - - // 6) Verify `fastly` and `fastly-sys` crate version - // - // A valid and up-to-date version of the fastly-sys crate is required. - if !filesystem.FileExists(fpath) { - return fmt.Errorf("%s not found", fpath) - } - - var metadata CargoMetadata - if err := metadata.Read(); err != nil { - return fmt.Errorf("error reading cargo metadata: %w", err) - } - - // Fetch the latest crate versions from cargo.io API. - latestFastly, err := getLatestCrateVersion(r.client, "fastly") - if err != nil { - return fmt.Errorf("error fetching latest crate version: %w", err) - } - - fastlySysConstraint, err := semver.NewConstraint(r.config.File.Language.Rust.FastlySysConstraint) - if err != nil { - return fmt.Errorf("error parsing latest crate version: %w", err) - } - - fastlySysVersion, err := getCrateVersionFromMetadata(metadata, "fastly-sys") - // If fastly-sys crate not found, error with dual remediation steps. - if err != nil { - return newCargoUpdateRemediationErr(err, latestFastly.String()) - } - - fastlyVersion, err := getCrateVersionFromMetadata(metadata, "fastly") - // If fastly crate not found, error with dual remediation steps. - if err != nil { - return newCargoUpdateRemediationErr(err, latestFastly.String()) - } - - // If fastly crate version is a prerelease, exit early. We assume that the - // user knows what they are doing and avoids any confusing messaging to - // "upgrade" to an older version. - if fastlyVersion.Prerelease() != "" { - return nil - } - - // If fastly-sys version doesn't meet our constraint, error with dual remediation steps. - if ok := fastlySysConstraint.Check(fastlySysVersion); !ok { - return newCargoUpdateRemediationErr(fmt.Errorf("fastly crate not up-to-date"), latestFastly.String()) - } - - // If fastly crate version is lower than the latest, suggest user should - // update, but don't error. - if fastlyVersion.LessThan(latestFastly) { - text.Break(out) - text.Info(out, fmt.Sprintf( - "an optional upgrade for the fastly crate is available, edit %s with:\n\n\t %s\n\nAnd then run the following command:\n\n\t$ %s\n", - text.Bold("Cargo.toml"), - text.Bold(fmt.Sprintf(`fastly = "^%s"`, latestFastly)), - text.Bold("cargo update -p fastly"), - )) - text.Break(out) - } - - return nil -} - -// Initialize implements the Toolchain interface and initializes a newly cloned -// package. It is a noop for Rust as the Cargo toolchain handles these steps. -func (r Rust) Initialize(out io.Writer) error { return nil } - -// Build implements the Toolchain interface and attempts to compile the package -// Rust source to a Wasm binary. -func (r Rust) Build(out io.Writer, verbose bool) error { - // Get binary name from Cargo.toml. - var m CargoManifest - if err := m.Read("Cargo.toml"); err != nil { - return fmt.Errorf("error reading Cargo.toml manifest: %w", err) - } - binName := m.Package.Name - - // Specify the toolchain using the `cargo +` syntax. - toolchain := fmt.Sprintf("+%s", r.config.File.Language.Rust.ToolchainVersion) - - args := []string{ - toolchain, - "build", - "--bin", - binName, - "--release", - "--target", - r.config.File.Language.Rust.WasmWasiTarget, - "--color", - "always", - } - if verbose { - args = append(args, "--verbose") - } - // Append debuginfo RUSTFLAGS to command environment to ensure DWARF debug - // information (such as, source mappings) are compiled into the binary. - rustflags := "-C debuginfo=2" - if val, ok := os.LookupEnv("RUSTFLAGS"); ok { - os.Setenv("RUSTFLAGS", fmt.Sprintf("%s %s", val, rustflags)) - } else { - os.Setenv("RUSTFLAGS", rustflags) - } - - // Execute the `cargo build` commands with the Wasm WASI target, release - // flags and env vars. - cmd := common.NewStreamingExec("cargo", args, os.Environ(), verbose, out) - if err := cmd.Exec(); err != nil { - return err - } - - // Get working directory. - dir, err := os.Getwd() - if err != nil { - return fmt.Errorf("getting current working directory: %w", err) - } - var metadata CargoMetadata - if err := metadata.Read(); err != nil { - return fmt.Errorf("error reading cargo metadata: %w", err) - } - src := filepath.Join(metadata.TargetDirectory, r.config.File.Language.Rust.WasmWasiTarget, "release", fmt.Sprintf("%s.wasm", binName)) - dst := filepath.Join(dir, "bin", "main.wasm") - - // Check if bin directory exists and create if not. - binDir := filepath.Join(dir, "bin") - if err := filesystem.MakeDirectoryIfNotExists(binDir); err != nil { - return fmt.Errorf("creating bin directory: %w", err) - } - - err = filesystem.CopyFile(src, dst) - if err != nil { - return fmt.Errorf("copying wasm binary: %w", err) - } - - return nil -} - -// CargoCrateVersion models a Cargo crate version returned by the crates.io API. -type CargoCrateVersion struct { - Version string `json:"num"` -} - -// CargoCrateVersions models a Cargo crate version returned by the crates.io API. -type CargoCrateVersions struct { - Versions []CargoCrateVersion `json:"versions"` -} - -// getLatestCrateVersion fetches all versions of a given Rust crate from the -// crates.io HTTP API and returns the latest valid semver version. -func getLatestCrateVersion(client api.HTTPClient, name string) (*semver.Version, error) { - url := fmt.Sprintf("https://crates.io/api/v1/crates/%s/versions", name) - - req, err := http.NewRequest("GET", url, nil) - if err != nil { - return nil, err - } - - resp, err := client.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("error fetching latest crate version: %s", resp.Status) - } - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - - crate := CargoCrateVersions{} - err = json.Unmarshal(body, &crate) - if err != nil { - return nil, err - } - - var versions []*semver.Version - for _, v := range crate.Versions { - // Parse version string and only append if not a prerelease. - if version, err := semver.NewVersion(v.Version); err == nil && version.Prerelease() == "" { - versions = append(versions, version) - } - } - - if len(versions) < 1 { - return nil, fmt.Errorf("no valid crate versions found") - } - - sort.Sort(semver.Collection(versions)) - - latest := versions[len(versions)-1] - - return latest, nil -} - -// getCrateVersionFromLockfile searches for a crate inside a CargoMetadata tree -// and returns the crates version as a semver.Version. -func getCrateVersionFromMetadata(metadata CargoMetadata, crate string) (*semver.Version, error) { - // Search for crate in metadata tree. - var c CargoPackage - for _, p := range metadata.Package { - if p.Name == crate { - c = p - break - } - for _, pp := range p.Dependencies { - if pp.Name == crate { - c = pp - break - } - } - } - - if c.Name == "" { - return nil, fmt.Errorf("%s crate not found", crate) - } - - // Parse lockfile version to semver.Version. - version, err := semver.NewVersion(c.Version) - if err != nil { - return nil, fmt.Errorf("error parsing cargo metadata: %w", err) - } - - return version, nil -} - -// newCargoUpdateRemediationErr constructs a new a new RemediationError which -// wraps a cargo error and suggests to update the fastly crate to a specified -// version as its remediation message. -func newCargoUpdateRemediationErr(err error, version string) errors.RemediationError { - return errors.RemediationError{ - Inner: err, - Remediation: fmt.Sprintf( - "To fix this error, edit %s with:\n\n\t %s\n\nAnd then run the following command:\n\n\t$ %s\n", - text.Bold("Cargo.toml"), - text.Bold(fmt.Sprintf(`fastly = "^%s"`, version)), - text.Bold("cargo update -p fastly"), - ), - } -} diff --git a/pkg/compute/testdata/build/assembly/index.ts b/pkg/compute/testdata/build/assembly/index.ts deleted file mode 100644 index c3e3e0955..000000000 --- a/pkg/compute/testdata/build/assembly/index.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { Request, Response, Fastly } from "@fastly/as-compute"; - -// The name of a backend server associated with this service. -// -// This should be changed to match the name of your own backend. See the the -// `Hosts` section of the Fastly Wasm service UI for more information. -const BACKEND_NAME = "backend_name"; - -/// The name of a second backend associated with this service. -const OTHER_BACKEND_NAME = "other_backend_name"; - -// The entry point for your application. -// -// Use this function to define your main request handling logic. It could be -// used to route based on the request properties (such as method or path), send -// the request to a backend, make completely new requests, and/or generate -// synthetic responses. -function main(req: Request): Response { - // Make any desired changes to the client request. - req.headers().set("Host", "example.com"); - - // We can filter requests that have unexpected methods. - const VALID_METHODS = ["HEAD", "GET", "POST"]; - if (!VALID_METHODS.includes(req.method())) { - return new Response(String.UTF8.encode("This method is not allowed"), { - status: 405, - }); - } - - let method = req.method(); - let urlParts = req.url().split("//").pop().split("/"); - let host = urlParts.shift(); - let path = "/" + urlParts.join("/"); - - // If request is a `GET` to the `/` path, send a default response. - if (method == "GET" && path == "/") { - return new Response(String.UTF8.encode("Welcome to Fastly Compute@Edge!"), { - status: 200, - }); - } - - // If request is a `GET` to the `/backend` path, send to a named backend. - if (method == "GET" && path == "/backend") { - // Request handling logic could go here... - // E.g., send the request to an origin backend and then cache the - // response for one minute. - let cacheOverride = new Fastly.CacheOverride(); - cacheOverride.setTTL(60); - return Fastly.fetch(req, { - backend: BACKEND_NAME, - cacheOverride, - }).wait(); - } - - // If request is a `GET` to a path starting with `/other/`. - if (method == "GET" && path.startsWith("/other/")) { - // Send request to a different backend and don't cache response. - let cacheOverride = new Fastly.CacheOverride(); - cacheOverride.setPass(); - return Fastly.fetch(req, { - backend: OTHER_BACKEND_NAME, - cacheOverride, - }).wait(); - } - - // Catch all other requests and return a 404. - return new Response(String.UTF8.encode("The page you requested could not be found"), { - status: 200, - }); -} - -// Get the request from the client. -let req = Fastly.getClientRequest(); - -// Pass the request to the main request handler function. -let resp = main(req); - -// Send the response back to the client. -Fastly.respondWith(resp); diff --git a/pkg/compute/testdata/build/package-lock.json b/pkg/compute/testdata/build/package-lock.json deleted file mode 100644 index 2d530de2b..000000000 --- a/pkg/compute/testdata/build/package-lock.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "name": "compute-starter-kit-assemblyscript-default", - "version": "1.0.0", - "lockfileVersion": 1, - "requires": true, - "dependencies": { - "@fastly/as-compute": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@fastly/as-compute/-/as-compute-0.1.1.tgz", - "integrity": "sha512-kuDYPQjY1o/v9HUEBkAwFYjeui6HCctj7vNGrl06r5ub5p1V8mkd6pqMn5muf6quQLmHkZZZLtKI+vLfYr9Ksw==", - "requires": { - "@fastly/as-fetch": "0.1.0" - } - }, - "@fastly/as-fetch": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@fastly/as-fetch/-/as-fetch-0.1.0.tgz", - "integrity": "sha512-CIsC8qDx0jZDcMWKxFz/IKyOdeFcvle/mnK6waZQkmeZIKAdSUuZ0AgJxT+AN2Qb5DGhtrYlkJgxkmNnSumpdA==" - }, - "assemblyscript": { - "version": "0.14.13", - "resolved": "https://registry.npmjs.org/assemblyscript/-/assemblyscript-0.14.13.tgz", - "integrity": "sha512-hV/2Zolfkzn55LrKymdsHLftkf8TXGf/ZDUooN10L9r1Zgf6yOIyIf/bgGq3A2l5lmUt669gk/Uwx2G0KCbCug==", - "dev": true, - "requires": { - "binaryen": "97.0.0-nightly.20200929", - "long": "^4.0.0" - } - }, - "binaryen": { - "version": "97.0.0-nightly.20200929", - "resolved": "https://registry.npmjs.org/binaryen/-/binaryen-97.0.0-nightly.20200929.tgz", - "integrity": "sha512-HQ7VTISqwfVOylWJAE2jIyhuO5zrxTD2Vvc0cwtXTUqfmlbZdt/Z0vxUZD+uFYHRLM0p9ddJc7RPVGsvPI2oEQ==", - "dev": true - }, - "long": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz", - "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==", - "dev": true - } - } -} diff --git a/pkg/compute/testdata/build/package.json b/pkg/compute/testdata/build/package.json deleted file mode 100644 index f2c175ef2..000000000 --- a/pkg/compute/testdata/build/package.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "name": "compute-starter-kit-assemblyscript-default", - "version": "1.0.0", - "description": "Default package starter kit for AssemblyScript based Compute@Edge projects", - "main": "src/index.ts", - "repository": { - "type": "git", - "url": "git+https://github.com/fastly/compute-starter-kit-assemblyscript-default.git" - }, - "keywords": [], - "author": "oss@fastly.com", - "license": "MIT", - "bugs": { - "url": "https://github.com/fastly/compute-starter-kit-assemblyscript-default/issues" - }, - "homepage": "https://github.com/fastly/compute-starter-kit-assemblyscript-default#readme", - "devDependencies": { - "assemblyscript": "^0.14.11" - }, - "dependencies": { - "@fastly/as-compute": "^0.1.1" - } -} diff --git a/pkg/compute/testdata/init/fastly-missing-spec-url.toml b/pkg/compute/testdata/init/fastly-missing-spec-url.toml deleted file mode 100644 index f51c22641..000000000 --- a/pkg/compute/testdata/init/fastly-missing-spec-url.toml +++ /dev/null @@ -1,5 +0,0 @@ -manifest_version = 1 -name = "Default Rust template" -description = "Default package template for Rust based edge compute projects." -authors = ["phamann "] -language = "rust" diff --git a/pkg/compute/testdata/init/fastly-valid-integer.toml b/pkg/compute/testdata/init/fastly-valid-integer.toml deleted file mode 100644 index f51c22641..000000000 --- a/pkg/compute/testdata/init/fastly-valid-integer.toml +++ /dev/null @@ -1,5 +0,0 @@ -manifest_version = 1 -name = "Default Rust template" -description = "Default package template for Rust based edge compute projects." -authors = ["phamann "] -language = "rust" diff --git a/pkg/compute/testdata/init/fastly-valid-semver.toml b/pkg/compute/testdata/init/fastly-valid-semver.toml deleted file mode 100644 index 678966b0a..000000000 --- a/pkg/compute/testdata/init/fastly-valid-semver.toml +++ /dev/null @@ -1,5 +0,0 @@ -manifest_version = "0.1.0" -name = "Default Rust template" -description = "Default package template for Rust based edge compute projects." -authors = ["phamann "] -language = "rust" diff --git a/pkg/compute/update.go b/pkg/compute/update.go deleted file mode 100644 index cf5602fa0..000000000 --- a/pkg/compute/update.go +++ /dev/null @@ -1,62 +0,0 @@ -package compute - -import ( - "fmt" - "io" - - "github.com/fastly/cli/pkg/api" - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// UpdateCommand calls the Fastly API to update packages. -type UpdateCommand struct { - common.Base - serviceID string - version int - path string -} - -// NewUpdateCommand returns a usable command registered under the parent. -func NewUpdateCommand(parent common.Registerer, client api.HTTPClient, globals *config.Data) *UpdateCommand { - var c UpdateCommand - c.Globals = globals - c.CmdClause = parent.Command("update", "Update a package on a Fastly Compute@Edge service version") - c.CmdClause.Flag("service-id", "Service ID").Short('s').Required().StringVar(&c.serviceID) - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.version) - c.CmdClause.Flag("path", "Path to package").Required().Short('p').StringVar(&c.path) - return &c -} - -// Exec invokes the application logic for the command. -func (c *UpdateCommand) Exec(in io.Reader, out io.Writer) (err error) { - // Exit early if no token configured. - _, s := c.Globals.Token() - if s == config.SourceUndefined { - return errors.ErrNoToken - } - - progress := text.NewQuietProgress(out) - defer func() { - if err != nil { - progress.Fail() // progress.Done is handled inline - } - }() - - progress.Step("Uploading package...") - _, err = c.Globals.Client.UpdatePackage(&fastly.UpdatePackageInput{ - ServiceID: c.serviceID, - ServiceVersion: c.version, - PackagePath: c.path, - }) - if err != nil { - return fmt.Errorf("error uploading package: %w", err) - } - progress.Done() - - text.Success(out, "Updated package (service %s, version %v)", c.serviceID, c.version) - return nil -} diff --git a/pkg/compute/validate.go b/pkg/compute/validate.go deleted file mode 100644 index f3e87313e..000000000 --- a/pkg/compute/validate.go +++ /dev/null @@ -1,97 +0,0 @@ -package compute - -import ( - "fmt" - "io" - "os" - "path/filepath" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/text" - "github.com/mholt/archiver/v3" -) - -// validate is a utility function to determine whether a package is valid. -// It attemptes to unarchive and read a tar.gz file from a specfic path, -// if successful, it then iterates through (streams) each file in the archive -// checking the filename against a list of required files. If one of the files -// doesn't exist it returns an error. -func validate(path string) error { - file, err := os.Open(filepath.Clean(path)) - if err != nil { - return fmt.Errorf("error reading package: %w", err) - } - defer file.Close() // #nosec G307 - - tar := archiver.NewTarGz() - err = tar.Open(file, 0) - if err != nil { - return fmt.Errorf("error unarchiving package: %w", err) - } - defer tar.Close() - - files := map[string]bool{ - "fastly.toml": false, - "main.wasm": false, - } - - for { - f, err := tar.Read() - if err == io.EOF { - break - } - if err != nil { - return fmt.Errorf("error reading package: %w", err) - } - - for k := range files { - if k == f.Name() { - files[k] = true - } - } - - err = f.Close() - if err != nil { - return fmt.Errorf("error closing package: %w", err) - } - } - - for k, found := range files { - if !found { - return fmt.Errorf("error validating package: package must contain a %s file", k) - } - } - - return nil -} - -// ValidateCommand validates a package archive. -type ValidateCommand struct { - common.Base - path string -} - -// NewValidateCommand returns a usable command registered under the parent. -func NewValidateCommand(parent common.Registerer, globals *config.Data) *ValidateCommand { - var c ValidateCommand - c.Globals = globals - c.CmdClause = parent.Command("validate", "Validate a Compute@Edge package") - c.CmdClause.Flag("path", "Path to package").Required().Short('p').StringVar(&c.path) - return &c -} - -// Exec implements the command interface. -func (c *ValidateCommand) Exec(in io.Reader, out io.Writer) error { - p, err := filepath.Abs(c.path) - if err != nil { - return fmt.Errorf("error reading file path: %w", err) - } - - if err := validate(p); err != nil { - return err - } - - text.Success(out, "Validated package %s", p) - return nil -} diff --git a/pkg/config/config.go b/pkg/config/config.go new file mode 100644 index 000000000..00c237750 --- /dev/null +++ b/pkg/config/config.go @@ -0,0 +1,498 @@ +package config + +import ( + _ "embed" + "errors" + "fmt" + "io" + "os" + "path/filepath" + + toml "github.com/pelletier/go-toml" + + "github.com/fastly/cli/pkg/env" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/filesystem" + "github.com/fastly/cli/pkg/revision" + "github.com/fastly/cli/pkg/text" +) + +const ( + // DirectoryPermissions is the default directory permissions for the config file directory. + DirectoryPermissions = 0o700 + + // FilePermissions is the default file permissions for the config file. + FilePermissions = 0o600 +) + +var ( + // CurrentConfigVersion indicates the present config version. + CurrentConfigVersion int + + // ErrLegacyConfig indicates that the local configuration file is using the + // legacy format. + ErrLegacyConfig = errors.New("the configuration file is in the legacy format") + + // ErrInvalidConfig indicates that the configuration file used was invalid. + ErrInvalidConfig = errors.New("the configuration file is invalid") + + // RemediationManualFix indicates that the configuration file used was invalid + // and that the user rejected the use of the static config embedded into the + // compiled CLI binary and so the user must resolve their invalid config. + RemediationManualFix = "You'll need to manually fix any invalid configuration syntax." +) + +// LegacyUser represents the old toml configuration format. +// +// NOTE: this exists to catch situations where an existing CLI user upgrades +// their version of the CLI and ends up trying to use the latest iteration of +// the toml configuration. We don't want them to have to re-enter their email +// or token, so we'll decode the existing config file into the LegacyUser type +// and then extract those details later when constructing the proper File type. +// +// I had tried to make this an unexported type but it seemed the toml decoder +// would fail to unmarshal the configuration unless it was an exported type. +type LegacyUser struct { + Email string `toml:"email"` + Token string `toml:"token"` +} + +// Fastly represents fastly specific configuration. +type Fastly struct { + APIEndpoint string `toml:"api_endpoint"` + AccountEndpoint string `toml:"account_endpoint"` +} + +// WasmMetadata represents what metadata will be collected. +type WasmMetadata struct { + // BuildInfo represents information regarding the time taken for builds and + // compilation processes, helping us identify bottlenecks and optimize + // performance (enable/disable). + BuildInfo string `toml:"build_info"` + // MachineInfo represents general, non-identifying system specifications (CPU, + // RAM, operating system) to better understand the hardware landscape our CLI + // operates in (enable/disable). + MachineInfo string `toml:"machine_info"` + // PackageInfo represents packages and libraries utilized by your source code, + // enabling us to prioritize support for the most commonly used components + // (enable/disable). + PackageInfo string `toml:"package_info"` + // ScriptInfo represents the [scripts] section from the fastly.toml manifest. + ScriptInfo string `toml:"script_info"` +} + +// CLI represents CLI specific configuration. +type CLI struct { + // MetadataNoticeDisplayed indicates if the user has been notified of the + // metadata behaviours being enabled by default and how they can opt-out. + MetadataNoticeDisplayed bool `toml:"metadata_notice_displayed"` + // Version indicates the CLI configuration version. + // It is updated each time a change is made to the config structure. + Version string `toml:"version"` +} + +// Versioner represents GitHub assets configuration. +// e.g. viceroy, wasm-tools etc. +type Versioner struct { + // LastChecked is when the asset version was last checked. + LastChecked string `toml:"last_checked"` + // LatestVersion is the latest asset version at the time it is set. + LatestVersion string `toml:"latest_version"` + // TTL is how long the CLI waits before considering the asset version stale. + TTL string `toml:"ttl"` +} + +// Language represents Compute language specific configuration. +type Language struct { + Go Go `toml:"go"` + Rust Rust `toml:"rust"` +} + +// Go represents Go Compute language specific configuration. +type Go struct { + // TinyGoConstraint is the `tinygo` version that we support. + TinyGoConstraint string `toml:"tinygo_constraint"` + + // TinyGoConstraintFallback is a fallback `tinygo` version for users who have + // a pre-existing project with a 0.1.x Fastly Go SDK specified. + TinyGoConstraintFallback string `toml:"tinygo_constraint_fallback"` + + // ToolchainConstraint is the `go` version that we support with WASI. + ToolchainConstraint string `toml:"toolchain_constraint"` + + // ToolchainConstraintTinyGo is the `go` version that we support with TinyGo. + // + // We aim for go versions that support go modules by default. + // https://go.dev/blog/using-go-modules + ToolchainConstraintTinyGo string `toml:"toolchain_constraint_tinygo"` +} + +// Rust represents Rust Compute language specific configuration. +type Rust struct { + // ToolchainConstraint is the `rustup` toolchain constraint for the compiler + // that we support (a range is expected, e.g. >= 1.49.0 < 2.0.0). + ToolchainConstraint string `toml:"toolchain_constraint"` + + // WasmWasiTarget is the Rust compilation target for Wasi capable Wasm. + WasmWasiTarget string `toml:"wasm_wasi_target"` +} + +// Profiles represents multiple profile accounts. +type Profiles map[string]*Profile + +// Profile represents a specific profile account. +type Profile struct { + // AccessToken is used to acquire an API token. + AccessToken string `toml:"access_token" json:"access_token"` + // AccessTokenCreated indicates when the access token was created. + AccessTokenCreated int64 `toml:"access_token_created" json:"access_token_created"` + // AccessTokenTTL indicates when the access token needs to be replaced. + AccessTokenTTL int `toml:"access_token_ttl" json:"access_token_ttl"` + // CustomerID is the customer ID associated with the profile. + CustomerID string `toml:"customer_id" json:"customer_id"` + // CustomerName is the customer name associated with the profile. + CustomerName string `toml:"customer_name" json:"customer_name"` + // Default indicates if the profile is the default profile to use. + Default bool `toml:"default" json:"default"` + // Email is the email address associated with the token. + Email string `toml:"email" json:"email"` + // RefreshToken is used to acquire a new access token when it expires. + RefreshToken string `toml:"refresh_token" json:"refresh_token"` + // RefreshTokenCreated indicates when the refresh token was created. + RefreshTokenCreated int64 `toml:"refresh_token_created" json:"refresh_token_created"` + // RefreshTokenTTL indicates when the refresh token needs to be replaced. + RefreshTokenTTL int `toml:"refresh_token_ttl" json:"refresh_token_ttl"` + // Token is a temporary token used to interact with the Fastly API. + Token string `toml:"token" json:"token"` +} + +// StarterKitLanguages represents language specific starter kits. +type StarterKitLanguages struct { + Go []StarterKit `toml:"go"` + JavaScript []StarterKit `toml:"javascript"` + Rust []StarterKit `toml:"rust"` +} + +// StarterKit represents starter kit specific configuration. +type StarterKit struct { + Name string `toml:"name"` + Description string `toml:"description"` + Path string `toml:"path"` + Tag string `toml:"tag"` + Branch string `toml:"branch"` +} + +// ensureConfigDirExists creates the application configuration directory if it +// doesn't already exist. +func ensureConfigDirExists(path string) error { + basePath := filepath.Dir(path) + return filesystem.MakeDirectoryIfNotExists(basePath) +} + +// File represents our application toml configuration. +type File struct { + // CLI represents CLI specific configuration. + CLI CLI `toml:"cli"` + // ConfigVersion is the version of the config. + ConfigVersion int `toml:"config_version"` + // Fastly represents fastly specific configuration. + Fastly Fastly `toml:"fastly"` + // Language represents C@E language specific configuration. + Language Language `toml:"language"` + // Profiles represents multiple profile accounts. + Profiles Profiles `toml:"profile"` + // StarterKitLanguages represents language specific starter kits. + StarterKits StarterKitLanguages `toml:"starter-kits"` + // Viceroy represents viceroy specific configuration. + Viceroy Versioner `toml:"viceroy"` + // WasmMetadata represents what metadata will be collected. + WasmMetadata WasmMetadata `toml:"wasm-metadata"` + // WasmTools represents wasm-tools specific configuration. + WasmTools Versioner `toml:"wasm-tools"` + + // We store off a possible legacy configuration so that we can later extract + // the relevant email and token values that may pre-exist. + // + // NOTE: We set omitempty so when we write the in-memory data back to disk + // we'll cause the [user] block to be removed. If we didn't do this, then + // every time we run a command with --verbose we would see a message telling + // us our config.toml was in a legacy format, even though we would have + // already migrated the user data to the [profile] section. + LegacyUser LegacyUser `toml:"user,omitempty"` + + // Store the flag values for --auto-yes/--non-interactive as at the time of + // the config File construction we need these values and need to be stored so + // that other callers of File.Read() don't need to have the values passed + // around in function arguments. + // + // NOTE: These fields are private to prevent them being written back to disk, + // but it means we need to expose Setter methods. + autoYes bool + nonInteractive bool +} + +// SetAutoYes sets the associated flag value. +// This controls how the interactive prompts are handled. +func (f *File) SetAutoYes(v bool) { + f.autoYes = v +} + +// SetNonInteractive sets the associated flag value. +// This controls how the interactive prompts are handled. +func (f *File) SetNonInteractive(v bool) { + f.nonInteractive = v +} + +// NOTE: Static 👇 is public for the sake of the test suite. + +// Static is the embedded configuration file used by the CLI. +// +//go:embed config.toml +var Static []byte + +// Read decodes a disk file into an in-memory data structure. +// +// NOTE: If user local configuration can't be read, then we'll ask the user to +// confirm whether to use the static config embedded in the CLI binary. If the +// user local configuration is deemed to be invalid, then we'll automatically +// switch to the static config and migrate the user's profile data (if any). +func (f *File) Read( + path string, + in io.Reader, + out io.Writer, + errLog fsterr.LogInterface, + verbose bool, +) error { + // Ensure the static config is sound. This should never happen (tm). + // We are checking this earlier to simplify the code later on. + var staticConfig File + err := toml.Unmarshal(Static, &staticConfig) + if err != nil { + errLog.Add(err) + return invalidStaticConfigErr(err) + } + + CurrentConfigVersion = staticConfig.ConfigVersion + + // G304 (CWE-22): Potential file inclusion via variable. + // gosec flagged this: + // Disabling as we need to load the config.toml from the user's file system. + // This file is decoded into a predefined struct, any unrecognised fields are dropped. + /* #nosec */ + // nosemgrep: trailofbits.go.invalid-usage-of-modified-variable.invalid-usage-of-modified-variable + data, err := os.ReadFile(path) + if err != nil { + data = Static + } + + unmarshalErr := toml.Unmarshal(data, f) + if unmarshalErr != nil { + errLog.Add(unmarshalErr) + + // If the local disk config failed to be unmarshalled, then + // ask the user if they would like us to replace their config with the + // version embedded into the CLI binary. + + text.Break(out) + + if !f.autoYes { + replacement := "Replace it with a valid version? (any existing email/token data will be lost) [y/N] " + label := fmt.Sprintf("Your configuration file (%s) is invalid. %s", path, replacement) + cont, err := text.AskYesNo(out, label, in) + if err != nil { + return fmt.Errorf("error reading input: %w", err) + } + if !cont { + err := fsterr.RemediationError{ + Inner: fmt.Errorf("%v: %v", ErrInvalidConfig, unmarshalErr), + Remediation: RemediationManualFix, + } + errLog.Add(err) + return err + } + } + f = &staticConfig + } + + err = ensureConfigDirExists(path) + if err != nil { + errLog.Add(err) + return err + } + + if f.NeedsUpdating(data, out, errLog, verbose) { + return f.UseStatic(path) + } + + return nil +} + +// MigrateLegacy ensures legacy data is transitioned to config new format. +func (f *File) MigrateLegacy() { + if f.LegacyUser.Email != "" || f.LegacyUser.Token != "" { + if f.Profiles == nil { + f.Profiles = make(Profiles) + } + + // We keep the assignment separate just in case the user somehow has a + // config.toml with BOTH a populated [user] + [profile] section, and + // possibly even already has a default account of "user". + + key := "user" + if _, ok := f.Profiles[key]; ok { + key = "legacy" // avoid overriding the default + } + + f.Profiles[key] = &Profile{ + Default: true, + Email: f.LegacyUser.Email, + Token: f.LegacyUser.Token, + } + f.LegacyUser = LegacyUser{} + } +} + +// NeedsUpdating indicates if the application config needs updating. +func (f *File) NeedsUpdating(data []byte, out io.Writer, errLog fsterr.LogInterface, verbose bool) bool { + tree, err := toml.LoadBytes(data) + if err != nil { + // NOTE: We do not expect this error block to ever be hit because if we've + // already successfully called toml.Unmarshal, then calling toml.LoadBytes + // should equally be successful. + panic("LoadBytes failed but Unmarshal succeeded") + } + + switch { + case tree.Get("user") != nil: + // The top-level 'user' section is what we're using to identify whether the + // local config.toml file is using a legacy format. If we find that key, then + // we must delete the file and return an error so that the calling code can + // take the appropriate action of creating the file anew. + errLog.Add(ErrLegacyConfig) + + if verbose { + text.Output(out, ` + Found your local configuration file (required to use the CLI) was using a legacy format. + File will be updated to the latest format. + `) + text.Break(out) + } + return true + case f.ConfigVersion != CurrentConfigVersion: + // If the ConfigVersion doesn't match, then this suggests a breaking change + // divergence in either the user's config or the CLI's config. + if verbose { + text.Output(out, "Found your local configuration file (required to use the CLI) to be incompatible with the current CLI version. Your configuration will be migrated to a compatible configuration format.") + text.Break(out) + } + return true + case f.CLI.Version != revision.SemVer(revision.AppVersion): + // If the CLI.Version doesn't match the CLI binary version, then this suggests + // a version update. This _might_ include a breaking change in the CLI's + // logic/implementation, or a new starter kit, for example. + // In this case we update the config regardless to ensure the + // CLI.Version is up to date. + return true + } + + return false +} + +// UseStatic switches the in-memory configuration with the static version +// embedded into the CLI binary and writes it back to disk. +// +// NOTE: We will attempt to migrate the profile data. +func (f *File) UseStatic(path string) error { + err := toml.Unmarshal(Static, f) + if err != nil { + return invalidStaticConfigErr(err) + } + + f.CLI.Version = revision.SemVer(revision.AppVersion) + f.MigrateLegacy() + + err = ensureConfigDirExists(path) + if err != nil { + return err + } + + return f.Write(path) +} + +// Write encodes in-memory data to disk. +func (f *File) Write(path string) error { + // gosec flagged this: + // G304 (CWE-22): Potential file inclusion via variable + // + // Disabling as in most cases the input is determined by our own package. + // In other cases we want to control the input for testing purposes. + /* #nosec */ + fp, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_TRUNC, FilePermissions) + if err != nil { + return fmt.Errorf("error creating config file: %w", err) + } + encoder := toml.NewEncoder(fp) + // Remove leading spaces from the TOML file. + encoder.Indentation("") + if err := encoder.Encode(f); err != nil { + return fmt.Errorf("error writing to config file: %w", err) + } + if err := fp.Close(); err != nil { + return fmt.Errorf("error saving config file changes: %w", err) + } + + return nil +} + +// Environment represents all of the configuration parameters that can come +// from environment variables. +type Environment struct { + // AccountEndpoint is the env var we look in for the Accounts endpoint. + AccountEndpoint string + // APIEndpoint is the API endpoint to call. + APIEndpoint string + // APIToken is the env var we look in for the Fastly API token. + APIToken string + // DebugMode indicates to the CLI it can display debug information. + DebugMode string + // UseSSO indicates if user wants to use SSO/OAuth token flow. + // 1: enabled, 0: disabled. + UseSSO string + // WasmMetadataDisable is the env var we look in to disable all data + // collection related to a Wasm binary. + // Set to "true" to disable all forms of data collection. + WasmMetadataDisable string +} + +// Read populates the fields from the provided environment. +func (e *Environment) Read(state map[string]string) { + e.AccountEndpoint = state[env.AccountEndpoint] + e.APIEndpoint = state[env.APIEndpoint] + e.APIToken = state[env.APIToken] + e.DebugMode = state[env.DebugMode] + e.UseSSO = state[env.UseSSO] + e.WasmMetadataDisable = state[env.WasmMetadataDisable] +} + +// invalidStaticConfigErr generates an error to alert the user to an issue with +// the CLI's internal configuration. +func invalidStaticConfigErr(err error) error { + return fsterr.RemediationError{ + Inner: fmt.Errorf("%v: %v", ErrInvalidConfig, err), + Remediation: fsterr.InvalidStaticConfigRemediation, + } +} + +// FileName is the name of the application configuration file. +const FileName = "config.toml" + +// FilePath is the location of the fastly CLI application config file. +var FilePath = func() string { + if dir, err := os.UserConfigDir(); err == nil { + return filepath.Join(dir, "fastly", FileName) + } + if dir, err := os.UserHomeDir(); err == nil { + return filepath.Join(dir, ".fastly", FileName) + } + panic("unable to deduce user config dir or user home dir") +}() diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go new file mode 100644 index 000000000..25ec9d48a --- /dev/null +++ b/pkg/config/config_test.go @@ -0,0 +1,395 @@ +package config_test + +import ( + "bytes" + _ "embed" + "os" + "path/filepath" + "strings" + "testing" + + toml "github.com/pelletier/go-toml" + + "github.com/fastly/cli/pkg/config" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/testutil" +) + +//go:embed testdata/static/config.toml +var staticConfig []byte + +//go:embed testdata/static/config-invalid.toml +var staticConfigInvalid []byte + +type testReadScenario struct { + name string + remediation bool + staticConfig []byte + userConfigFilename string + userResponseToPrompt string + wantError string +} + +// TestConfigRead validates all logic flows within config.File.Read(). +func TestConfigRead(t *testing.T) { + scenarios := []testReadScenario{ + { + name: "static config should be used when there is no local user config", + userResponseToPrompt: "yes", // prompts asks user to confirm they want a static fallback + staticConfig: staticConfig, + }, + { + name: "static config should return an error when invalid", + userResponseToPrompt: "yes", // prompts asks user to confirm they want a static fallback + staticConfig: staticConfigInvalid, + wantError: config.ErrInvalidConfig.Error(), + }, + { + name: "when user config is invalid (and the user accepts static config) but static config is also invalid, it should return an error", + staticConfig: staticConfigInvalid, + userConfigFilename: "config-invalid.toml", + userResponseToPrompt: "yes", + wantError: config.ErrInvalidConfig.Error(), + }, + { + name: "when user config is invalid (and the user rejects static config), it should return a specific remediation error", + remediation: true, + staticConfig: staticConfig, + userConfigFilename: "config-invalid.toml", + userResponseToPrompt: "no", + wantError: config.RemediationManualFix, + }, + { + name: "when user config is in the legacy format, it should use static config", + staticConfig: staticConfig, + userConfigFilename: "config-legacy.toml", + userResponseToPrompt: "no", + }, + { + name: "when user config is valid, it should return no error", + staticConfig: staticConfig, + userConfigFilename: "config.toml", + }, + } + + for _, testcase := range scenarios { + t.Run(testcase.name, func(t *testing.T) { + // We're going to chdir to an temp environment, + // so save the PWD to return to, afterwards. + pwd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + + // Create test environment + backupStatic := config.Static + defer func() { + config.Static = backupStatic + }() + config.Static = testcase.staticConfig + opts := testutil.EnvOpts{T: t} + if testcase.userConfigFilename != "" { + b, err := os.ReadFile(filepath.Join("testdata", testcase.userConfigFilename)) + if err != nil { + t.Fatal(err) + } + opts.Write = []testutil.FileIO{ + {Src: string(b), Dst: "user-config.toml"}, + } + } + rootdir := testutil.NewEnv(opts) + configPath := filepath.Join(rootdir, "user-config.toml") + defer os.RemoveAll(rootdir) + + // Before running the test, chdir into the temp environment. + // When we're done, chdir back to our original location. + // This is so we can reliably assert file structure. + if err := os.Chdir(rootdir); err != nil { + t.Fatal(err) + } + defer func() { + _ = os.Chdir(pwd) + }() + + if testcase.userConfigFilename == "" { + if fi, err := os.Stat(configPath); err == nil { + t.Fatalf("expected the user config to NOT exist at this point: %+v", fi) + } + } else { + if _, err := os.Stat(configPath); err != nil { + t.Fatalf("expected the user config to exist at this point: %v", err) + } + } + + var out bytes.Buffer + in := strings.NewReader(testcase.userResponseToPrompt) + + mockLog := fsterr.MockLog{} + + var f config.File + err = f.Read(configPath, in, &out, mockLog, false) + + if testcase.remediation { + e, ok := err.(fsterr.RemediationError) + if !ok { + t.Fatalf("unexpected error asserting returned error (%T) to a RemediationError type", err) + } + if testcase.wantError != e.Remediation { + t.Fatalf("want %v, have %v", testcase.wantError, e.Remediation) + } + } else { + testutil.AssertErrorContains(t, err, testcase.wantError) + } + + if testcase.wantError == "" { + // If we're not expecting an error, then we're expecting the user + // configuration file to exist... + + if _, err := os.Stat(configPath); err == nil { + bs, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + + err = toml.Unmarshal(bs, &f) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + + if f.CLI.Version == "" { + t.Fatalf("expected CLI.Version to be set: %+v", f) + } + } + } + }) + } +} + +// TestUseStatic validates legacy user data is migrated successfully. +func TestUseStatic(t *testing.T) { + // We're going to chdir to an temp environment, + // so save the PWD to return to, afterwards. + pwd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + + // Create test environment + b, err := os.ReadFile(filepath.Join("testdata", "config-legacy.toml")) + if err != nil { + t.Fatal(err) + } + rootdir := testutil.NewEnv(testutil.EnvOpts{ + T: t, + Write: []testutil.FileIO{ + {Src: string(b), Dst: "user-config.toml"}, + }, + }) + legacyUserConfigPath := filepath.Join(rootdir, "user-config.toml") + defer os.RemoveAll(rootdir) + + // Before running the test, chdir into the temp environment. + // When we're done, chdir back to our original location. + // This is so we can reliably assert file structure. + if err := os.Chdir(rootdir); err != nil { + t.Fatal(err) + } + defer func() { + _ = os.Chdir(pwd) + }() + + var out bytes.Buffer + + // Validate that legacy configuration can be migrated to the static one + // embedded in the CLI binary. + f := config.File{} + err = f.Read(legacyUserConfigPath, strings.NewReader(""), &out, fsterr.MockLog{}, false) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + + if f.CLI.Version == "" { + t.Fatalf("expected CLI.Version to be set: %+v", f) + } + if f.Profiles["user"].Token != "foobar" { + t.Fatalf("wanted token: %s, got: %s", "foobar", f.LegacyUser.Token) + } + if f.Profiles["user"].Email != "testing@fastly.com" { + t.Fatalf("wanted email: %s, got: %s", "testing@fastly.com", f.LegacyUser.Email) + } + if !f.Profiles["user"].Default { + t.Fatal("expected the migrated user to become the default") + } + + // We validate both the in-memory data structure (above) AND the file on disk (below). + data, err := os.ReadFile(legacyUserConfigPath) + if err != nil { + t.Error(err) + } + if strings.Contains(string(data), "[user]") { + t.Error("expected legacy [user] section to be removed") + } + if !strings.Contains(string(data), `[profile.user] +access_token = "" +access_token_created = 0 +access_token_ttl = 0 +customer_id = "" +customer_name = "" +default = true +email = "testing@fastly.com" +refresh_token = "" +refresh_token_created = 0 +refresh_token_ttl = 0 +token = "foobar"`) { + t.Errorf("expected legacy [user] section to be migrated to [profile.user]: %s", string(data)) + } + + // Validate that invalid static configuration returns a specific error. + // + // NOTE: By providing a legacy config, we'll cause the static config embedded + // into the CLI to be used, and we'll migrate the legacy data to the new + // format, but by specifying the static config as being invalid we expect the + // CLI to return the error. + backupStatic := config.Static + defer func() { + config.Static = backupStatic + }() + config.Static = staticConfigInvalid + f = config.File{} + err = f.Read(legacyUserConfigPath, strings.NewReader(""), &out, fsterr.MockLog{}, false) + if err == nil { + t.Fatal("expected an error, but got nil") + } else { + testutil.AssertErrorContains(t, err, config.ErrInvalidConfig.Error()) + } +} + +type testInvalidConfigScenario struct { + testutil.CLIScenario + + invalid bool + staticConfig []byte + userConfig string +} + +// TestInvalidConfig validates all logic flows within config.File.ValidConfig() +// +// NOTE: Even with invalid config we expect the static config embedded with the +// CLI to be utilised. +func TestInvalidConfig(t *testing.T) { + s1 := testInvalidConfigScenario{} + s1.Name = "invalid config version, invalid cli version" + s1.invalid = true + s1.staticConfig = staticConfig + s1.userConfig = "config-incompatible-config-version.toml" + + s2 := testInvalidConfigScenario{} + s2.Name = "valid config version, invalid cli version" + s2.invalid = false + s2.staticConfig = staticConfig + s2.userConfig = "config.toml" + + scenarios := []testInvalidConfigScenario{s1, s2} + + for testcaseIdx := range scenarios { + testcase := &scenarios[testcaseIdx] + t.Run(testcase.Name, func(t *testing.T) { + // We're going to chdir to an temp environment, + // so save the PWD to return to, afterwards. + pwd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + + // Create test environment + rootdir := testutil.NewEnv(testutil.EnvOpts{ + T: t, + Copy: []testutil.FileIO{ + { + Src: filepath.Join("testdata", testcase.userConfig), + Dst: "config.toml", + }, + }, + }) + configPath := filepath.Join(rootdir, "config.toml") + defer os.RemoveAll(rootdir) + + // Before running the test, chdir into the temp environment. + // When we're done, chdir back to our original location. + // This is so we can reliably assert file structure. + if err := os.Chdir(rootdir); err != nil { + t.Fatal(err) + } + defer func() { + _ = os.Chdir(pwd) + }() + + var f config.File + var stdout bytes.Buffer + config.Static = testcase.staticConfig + + in := strings.NewReader("") // these tests won't trigger a user prompt + err = f.Read(configPath, in, &stdout, nil, true) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + output := strings.ReplaceAll(stdout.String(), "\n", " ") + if testcase.invalid { + testutil.AssertStringContains(t, output, "incompatible with the current CLI version") + } + }) + } +} + +func TestNeedsUpdating(t *testing.T) { + t.Parallel() + + config.CurrentConfigVersion = 2 + + tests := []struct { + name string + filename string + want bool + }{ + { + "legacy config should be updated", + "config-legacy.toml", + true, + }, + { + "outdated config_version config should be updated", + "config.toml", + true, + }, + { + "mismatching CLI version config should be updated", + "config-outdated-cli-version.toml", + true, + }, + { + "current config should not be updated", + "config-current.toml", + false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + data, err := os.ReadFile(filepath.Join("testdata", tt.filename)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + var f config.File + if err = toml.Unmarshal(data, &f); err != nil { + t.Fatalf("unexpected error: %v", err) + } + var stdout bytes.Buffer + mockLog := fsterr.MockLog{} + result := f.NeedsUpdating(data, &stdout, mockLog, true) + if result != tt.want { + t.Fatalf("expected %v got %v", tt.want, result) + } + }) + } +} diff --git a/pkg/config/data.go b/pkg/config/data.go deleted file mode 100644 index 7233d041f..000000000 --- a/pkg/config/data.go +++ /dev/null @@ -1,371 +0,0 @@ -package config - -import ( - "context" - "errors" - "fmt" - "io" - "net/http" - "os" - "path/filepath" - "time" - - "github.com/fastly/cli/pkg/api" - "github.com/fastly/cli/pkg/filesystem" - "github.com/fastly/cli/pkg/revision" - "github.com/fastly/cli/pkg/useragent" - toml "github.com/pelletier/go-toml" -) - -// Source enumerates where a config parameter is taken from. -type Source uint8 - -const ( - // SourceUndefined indicates the parameter isn't provided in any of the - // available sources, similar to "not found". - SourceUndefined Source = iota - - // SourceFile indicates the parameter came from a config file. - SourceFile - - // SourceEnvironment indicates the parameter came from an env var. - SourceEnvironment - - // SourceFlag indicates the parameter came from an explicit flag. - SourceFlag - - // SourceDefault indicates the parameter came from a program default. - SourceDefault - - // DirectoryPermissions is the default directory permissions for the config file directory. - DirectoryPermissions = 0700 - - // FilePermissions is the default file permissions for the config file. - FilePermissions = 0600 - - // RemoteEndpoint represents the API endpoint where we'll pull the dynamic - // configuration file from. - // - // NOTE: once the configuration is stored locally, it will allow for - // overriding this default endpoint. - RemoteEndpoint = "https://developer.fastly.com/api/internal/cli-config" - - // UpdateSuccessful represents the message shown to a user when their - // application configuration has been updated successfully. - UpdateSuccessful = "Successfully updated platform compatibility and versioning information." - - // ConfigRequestTimeout is how long we'll wait for the CLI API endpoint to - // return a response before timing out the request. - ConfigRequestTimeout = 5 * time.Second -) - -// ErrLegacyConfig indicates that the local configuration file is using the -// legacy format. -var ErrLegacyConfig = errors.New("the configuration file is in the legacy format") - -// Data holds global-ish configuration data from all sources: environment -// variables, config files, and flags. It has methods to give each parameter to -// the components that need it, including the place the parameter came from, -// which is a requirement. -// -// If the same parameter is defined in multiple places, it is resolved according -// to the following priority order: the config file (lowest priority), env vars, -// and then explicit flags (highest priority). -// -// This package and its types are only meant for parameters that are applicable -// to most/all subcommands (e.g. API token) and are consistent for a given user -// (e.g. an email address). Otherwise, parameters should be defined in specific -// command structs, and parsed as flags. -type Data struct { - File File - Env Environment - Output io.Writer - Flag Flag - - Client api.Interface - RTSClient api.RealtimeStatsInterface -} - -// Token yields the Fastly API token. -func (d *Data) Token() (string, Source) { - if d.Flag.Token != "" { - return d.Flag.Token, SourceFlag - } - - if d.Env.Token != "" { - return d.Env.Token, SourceEnvironment - } - - if d.File.User.Token != "" { - return d.File.User.Token, SourceFile - } - - return "", SourceUndefined -} - -// Verbose yields the verbose flag, which can only be set via flags. -func (d *Data) Verbose() bool { - return d.Flag.Verbose -} - -// Endpoint yields the API endpoint. -func (d *Data) Endpoint() (string, Source) { - if d.Flag.Endpoint != "" { - return d.Flag.Endpoint, SourceFlag - } - - if d.Env.Endpoint != "" { - return d.Env.Endpoint, SourceEnvironment - } - - if d.File.Fastly.APIEndpoint != DefaultEndpoint && d.File.Fastly.APIEndpoint != "" { - return d.File.Fastly.APIEndpoint, SourceFile - } - - return DefaultEndpoint, SourceDefault // this method should not fail -} - -// FilePath is the location of the fastly CLI application config file. -var FilePath = func() string { - if dir, err := os.UserConfigDir(); err == nil { - return filepath.Join(dir, "fastly", "config.toml") - } - if dir, err := os.UserHomeDir(); err == nil { - return filepath.Join(dir, ".fastly", "config.toml") - } - panic("unable to deduce user config dir or user home dir") -}() - -// DefaultEndpoint is the default Fastly API endpoint. -const DefaultEndpoint = "https://api.fastly.com" - -// LegacyFile represents the old toml configuration format. -// -// NOTE: this exists to catch situations where an existing CLI user upgrades -// their version of the CLI and ends up trying to use the latest iteration of -// the toml configuration. We don't want them to have to re-enter their email -// or token, so we'll decode the existing config file into the LegacyFile type -// and then extract those details later when constructing the proper File type. -// -// I had tried to make this an unexported type but it seemed the toml decoder -// would fail to unmarshal the configuration unless it was an exported type. -type LegacyFile struct { - Token string `toml:"token"` - Email string `toml:"email"` -} - -// File represents our dynamic application toml configuration. -type File struct { - Fastly Fastly `toml:"fastly"` - CLI CLI `toml:"cli"` - User User `toml:"user"` - Language Language `toml:"language"` - StarterKits StarterKitLanguages `toml:"starter-kits"` - - // We store off a possible legacy configuration so that we can later extract - // the relevant email and token values that may pre-exist. - Legacy LegacyFile `toml:"legacy"` -} - -// Fastly represents fastly specific configuration. -type Fastly struct { - APIEndpoint string `toml:"api_endpoint"` -} - -// CLI represents CLI specific configuration. -type CLI struct { - RemoteConfig string `toml:"remote_config"` - TTL string `toml:"ttl"` - LastChecked string `toml:"last_checked"` - Version string `toml:"version"` -} - -// User represents user specific configuration. -type User struct { - Token string `toml:"token"` - Email string `toml:"email"` -} - -// Language represents C@E language specific configuration. -type Language struct { - Rust Rust `toml:"rust"` -} - -// Rust represents Rust C@E language specific configuration. -type Rust struct { - // ToolchainVersion is the `rustup` toolchain string for the compiler that we - // support - ToolchainVersion string `toml:"toolchain_version"` - - // WasmWasiTarget is the Rust compilation target for Wasi capable Wasm. - WasmWasiTarget string `toml:"wasm_wasi_target"` - - // FastlySysConstraint is a free-form semver constraint for the internal Rust - // ABI version that should be supported. - FastlySysConstraint string `toml:"fastly_sys_constraint"` - - // RustupConstraint is a free-form semver constraint for the rustup version - // that should be installed. - RustupConstraint string `toml:"rustup_constraint"` -} - -// StarterKitLanguages represents language specific starter kits. -type StarterKitLanguages struct { - AssemblyScript []StarterKit `toml:"assemblyscript"` - Rust []StarterKit `toml:"rust"` -} - -// StarterKit represents starter kit specific configuration. -type StarterKit struct { - Name string `toml:"name"` - Path string `toml:"path"` - Tag string `toml:"tag"` - Branch string `toml:"branch"` -} - -// Load gets the configuration file from the CLI API endpoint and encodes it -// from memory into config.File. -func (f *File) Load(configEndpoint string, httpClient api.HTTPClient) error { - ctx, cancel := context.WithTimeout(context.Background(), ConfigRequestTimeout) - defer cancel() - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, configEndpoint, nil) - if err != nil { - return err - } - - req.Header.Set("User-Agent", useragent.Name) - resp, err := httpClient.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("fetching remote configuration: expected '200 OK', received '%s'", resp.Status) - } - - err = toml.NewDecoder(resp.Body).Decode(f) - if err != nil { - return err - } - - f.CLI.Version = revision.SemVer(revision.AppVersion) - f.CLI.LastChecked = time.Now().Format(time.RFC3339) - - if f.Legacy.Token != "" && f.User.Token == "" { - f.User.Token = f.Legacy.Token - } - - if f.Legacy.Email != "" && f.User.Email == "" { - f.User.Email = f.Legacy.Email - } - - // Create the destination directory for the config file - basePath := filepath.Dir(FilePath) - err = filesystem.MakeDirectoryIfNotExists(basePath) - if err != nil { - return err - } - - // Write the new configuration back to disk. - return f.Write(FilePath) -} - -// Read decodes a toml file from the local disk into config.File. -func (f *File) Read(fpath string) error { - // G304 (CWE-22): Potential file inclusion via variable. - // gosec flagged this: - // Disabling as we need to load the config.toml from the user's file system. - // This file is decoded into a predefined struct, any unrecognised fields are dropped. - /* #nosec */ - bs, err := os.ReadFile(fpath) - if err != nil { - return err - } - err = toml.Unmarshal(bs, f) - if err != nil { - return err - } - - // The top-level 'endpoint' key is what we're using to identify whether the - // local config.toml file is using the legacy format. If we find that key, - // then we must delete the file and return an error so that the calling code - // can take the appropriate action of creating the file anew. - tree, err := toml.LoadBytes(bs) - if err != nil { - return err - } - - if endpoint := tree.Get("endpoint"); endpoint != nil { - var lf LegacyFile - - err := toml.Unmarshal(bs, &lf) - if err != nil { - return err - } - - f.Legacy = lf - - return ErrLegacyConfig - } - - return nil -} - -// Write the instance of File to a local application config file. -// -// NOTE: the expected workflow for this method is for the caller to have -// modified the public field(s) first so that we can write new content to the -// config file from the receiver object itself. -// -// EXAMPLE: -// file.CLI.LastChecked = time.Now().Format(time.RFC3339) -// file.Write(configFilePath) -func (f *File) Write(filename string) error { - fp, err := os.OpenFile(filename, os.O_RDWR|os.O_CREATE|os.O_TRUNC, FilePermissions) - if err != nil { - return fmt.Errorf("error creating config file: %w", err) - } - if err := toml.NewEncoder(fp).Encode(f); err != nil { - return fmt.Errorf("error writing config file: %w", err) - } - if err := fp.Close(); err != nil { - return fmt.Errorf("error saving config file: %w", err) - } - return nil -} - -// Environment represents all of the configuration parameters that can come -// from environment variables. -type Environment struct { - Token string - Endpoint string -} - -const ( - // EnvVarToken is the env var we look in for the Fastly API token. - // gosec flagged this: - // G101 (CWE-798): Potential hardcoded credentials - // Disabling as we use the value in the command help output. - /* #nosec */ - EnvVarToken = "FASTLY_API_TOKEN" - - // EnvVarEndpoint is the env var we look in for the API endpoint. - EnvVarEndpoint = "FASTLY_API_ENDPOINT" -) - -// Read populates the fields from the provided environment. -func (e *Environment) Read(env map[string]string) { - e.Token = env[EnvVarToken] - e.Endpoint = env[EnvVarEndpoint] -} - -// Flag represents all of the configuration parameters that can be set with -// explicit flags. Consumers should bind their flag values to these fields -// directly. -type Flag struct { - Token string - Verbose bool - Endpoint string -} diff --git a/pkg/config/testdata/config-current.toml b/pkg/config/testdata/config-current.toml new file mode 100644 index 000000000..e86493c53 --- /dev/null +++ b/pkg/config/testdata/config-current.toml @@ -0,0 +1,7 @@ +config_version = 2 + +[fastly] +api_endpoint = "https://api.fastly.com" + +[cli] +version = "0.0.0" # this matches the dev version diff --git a/pkg/config/testdata/config-incompatible-config-version.toml b/pkg/config/testdata/config-incompatible-config-version.toml new file mode 100644 index 000000000..78e9543ee --- /dev/null +++ b/pkg/config/testdata/config-incompatible-config-version.toml @@ -0,0 +1,27 @@ +config_version = 0 # we expect the embedded config to be >= 1 + +[fastly] +api_endpoint = "https://api.fastly.com" + +[cli] +remote_config = "https://developer.fastly.com/api/internal/cli-config" +ttl = "5m" +last_checked = "2021-06-18T15:13:34+01:00" +version = "0.0.1" + +[language] +[language.rust] +# we're missing the 'toolchain_constraint' property +wasm_wasi_target = "wasm32-wasip1" + +[starter-kits] +[[starter-kits.rust]] +name = "Default" +path = "https://github.com/fastly/compute-starter-kit-rust-default.git" +branch = "0.7" +[[starter-kits.rust]] +name = "Beacon" +path = "https://github.com/fastly/compute-starter-kit-rust-beacon-termination.git" +[[starter-kits.rust]] +name = "Static" +path = "https://github.com/fastly/compute-starter-kit-rust-static-content.git" diff --git a/pkg/config/testdata/config-invalid.toml b/pkg/config/testdata/config-invalid.toml new file mode 100644 index 000000000..c05ca8573 --- /dev/null +++ b/pkg/config/testdata/config-invalid.toml @@ -0,0 +1,2 @@ +[fastly] +api_endpoint = "https://api.fastly.com # missing end quote diff --git a/pkg/config/testdata/config-legacy.toml b/pkg/config/testdata/config-legacy.toml new file mode 100644 index 000000000..24a423cb7 --- /dev/null +++ b/pkg/config/testdata/config-legacy.toml @@ -0,0 +1,6 @@ +[fastly] +api_endpoint = "https://api.fastly.com" + +[user] + email = "testing@fastly.com" + token = "foobar" diff --git a/pkg/config/testdata/config-outdated-cli-version.toml b/pkg/config/testdata/config-outdated-cli-version.toml new file mode 100644 index 000000000..685c151c0 --- /dev/null +++ b/pkg/config/testdata/config-outdated-cli-version.toml @@ -0,0 +1,7 @@ +config_version = 2 + +[fastly] +api_endpoint = "https://api.fastly.com" + +[cli] +version = "1.2.3" diff --git a/pkg/config/testdata/config.toml b/pkg/config/testdata/config.toml new file mode 100644 index 000000000..97a6bc11d --- /dev/null +++ b/pkg/config/testdata/config.toml @@ -0,0 +1,33 @@ +config_version = 1 + +[fastly] +api_endpoint = "https://api.fastly.com" + +[cli] +remote_config = "https://developer.fastly.com/api/internal/cli-config" +ttl = "5m" +last_checked = "2021-06-18T15:13:34+01:00" +version = "0.0.1" + +[language] + [language.rust] + toolchain_constraint = ">= 1.78.0" + wasm_wasi_target = "wasm32-wasip1" + +[starter-kits] +[[starter-kits.javascript]] +name = "Default" +description = "A basic starter kit that demonstrates routing and simple synthetic responses." +path = "https://github.com/fastly/compute-starter-kit-javascript-default" +[[starter-kits.rust]] +name = "Default" +description = "A basic starter kit that demonstrates routing, simple synthetic responses and overriding caching rules." +path = "https://github.com/fastly/compute-starter-kit-rust-default" +[[starter-kits.rust]] +name = "Beacon" +description = "Capture beacon data from the browser, divert beacon request payloads to a log endpoint, and avoid putting load on your own infrastructure." +path = "https://github.com/fastly/compute-starter-kit-rust-beacon-termination" +[[starter-kits.rust]] +name = "Static" +description = "Apply performance, security and usability upgrades to static bucket services such as Google Cloud Storage or AWS S3." +path = "https://github.com/fastly/compute-starter-kit-rust-static-content" diff --git a/pkg/config/testdata/static/config-invalid.toml b/pkg/config/testdata/static/config-invalid.toml new file mode 100644 index 000000000..c05ca8573 --- /dev/null +++ b/pkg/config/testdata/static/config-invalid.toml @@ -0,0 +1,2 @@ +[fastly] +api_endpoint = "https://api.fastly.com # missing end quote diff --git a/pkg/config/testdata/static/config.toml b/pkg/config/testdata/static/config.toml new file mode 100644 index 000000000..7bb2901c5 --- /dev/null +++ b/pkg/config/testdata/static/config.toml @@ -0,0 +1,31 @@ +config_version = 1 + +[fastly] +api_endpoint = "https://api.fastly.com" + +[cli] +remote_config = "https://developer.fastly.com/api/internal/cli-config" +ttl = "5m" + +[language] +[language.rust] +toolchain_constraint = ">= 1.49.0 < 2.0.0" +wasm_wasi_target = "wasm32-wasip1" + +[starter-kits] +[[starter-kits.javascript]] +name = "Default" +description = "A basic starter kit that demonstrates routing and simple synthetic responses." +path = "https://github.com/fastly/compute-starter-kit-javascript-default" +[[starter-kits.rust]] +name = "Default" +description = "A basic starter kit that demonstrates routing, simple synthetic responses and overriding caching rules." +path = "https://github.com/fastly/compute-starter-kit-rust-default" +[[starter-kits.rust]] +name = "Beacon" +description = "Capture beacon data from the browser, divert beacon request payloads to a log endpoint, and avoid putting load on your own infrastructure." +path = "https://github.com/fastly/compute-starter-kit-rust-beacon-termination" +[[starter-kits.rust]] +name = "Static" +description = "Apply performance, security and usability upgrades to static bucket services such as Google Cloud Storage or AWS S3." +path = "https://github.com/fastly/compute-starter-kit-rust-static-content" diff --git a/pkg/configure/configure_test.go b/pkg/configure/configure_test.go deleted file mode 100644 index accbeb3c0..000000000 --- a/pkg/configure/configure_test.go +++ /dev/null @@ -1,354 +0,0 @@ -package configure_test - -import ( - "bytes" - "errors" - "io" - "net/http" - "os" - "strings" - "testing" - - "github.com/fastly/cli/pkg/app" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/mock" - "github.com/fastly/cli/pkg/testutil" - "github.com/fastly/cli/pkg/update" - "github.com/fastly/go-fastly/v3/fastly" -) - -func TestConfigure(t *testing.T) { - var ( - goodToken = func() (*fastly.Token, error) { return &fastly.Token{}, nil } - badToken = func() (*fastly.Token, error) { return nil, errors.New("bad token") } - goodUser = func(*fastly.GetUserInput) (*fastly.User, error) { - return &fastly.User{ - Login: "test@example.com", - }, nil - } - badUser = func(*fastly.GetUserInput) (*fastly.User, error) { return nil, errors.New("bad user") } - ) - - for _, testcase := range []struct { - name string - args []string - env config.Environment - file config.File - api mock.API - configFileData string - stdin string - wantError string - wantOutput []string - wantFile string - }{ - { - name: "endpoint from flag", - args: []string{"configure", "--endpoint=http://local.dev", "--token=abcdef"}, - api: mock.API{ - GetTokenSelfFn: goodToken, - GetUserFn: goodUser, - }, - wantOutput: []string{ - "Fastly API endpoint (via --endpoint): http://local.dev", - "Fastly API token provided via --token", - "Validating token...", - "Persisting configuration...", - "Configured the Fastly CLI", - "You can find your configuration file at", - }, - wantFile: ` -[cli] - last_checked = "" - remote_config = "" - ttl = "" - version = "" - -[fastly] - api_endpoint = "http://local.dev" - -[language] - - [language.rust] - fastly_sys_constraint = "" - rustup_constraint = "" - toolchain_version = "" - wasm_wasi_target = "" - -[legacy] - email = "" - token = "" - -[starter-kits] - -[user] - email = "test@example.com" - token = "abcdef" -`, - }, - { - name: "endpoint already in file should be replaced by flag", - args: []string{"configure", "--endpoint=http://staging.dev", "--token=abcdef"}, - configFileData: "endpoint = \"https://api.fastly.com\"", - stdin: "new_token\n", - api: mock.API{ - GetTokenSelfFn: goodToken, - GetUserFn: goodUser, - }, - wantOutput: []string{ - "Fastly API endpoint (via --endpoint): http://staging.dev", - "Fastly API token provided via --token", - "Validating token...", - "Persisting configuration...", - "Configured the Fastly CLI", - "You can find your configuration file at", - }, - wantFile: ` -[cli] - last_checked = "" - remote_config = "" - ttl = "" - version = "" - -[fastly] - api_endpoint = "http://staging.dev" - -[language] - - [language.rust] - fastly_sys_constraint = "" - rustup_constraint = "" - toolchain_version = "" - wasm_wasi_target = "" - -[legacy] - email = "" - token = "" - -[starter-kits] - -[user] - email = "test@example.com" - token = "abcdef" -`, - }, - { - name: "token from flag", - args: []string{"configure", "--token=abcdef"}, - api: mock.API{ - GetTokenSelfFn: goodToken, - GetUserFn: goodUser, - }, - wantOutput: []string{ - "Fastly API token provided via --token", - "Validating token...", - "Persisting configuration...", - "Configured the Fastly CLI", - "You can find your configuration file at", - }, - wantFile: ` -[cli] - last_checked = "" - remote_config = "" - ttl = "" - version = "" - -[fastly] - api_endpoint = "https://api.fastly.com" - -[language] - - [language.rust] - fastly_sys_constraint = "" - rustup_constraint = "" - toolchain_version = "" - wasm_wasi_target = "" - -[legacy] - email = "" - token = "" - -[starter-kits] - -[user] - email = "test@example.com" - token = "abcdef" -`, - }, - { - name: "token from interactive input", - args: []string{"configure"}, - stdin: "1234\n", - api: mock.API{ - GetTokenSelfFn: goodToken, - GetUserFn: goodUser, - }, - wantOutput: []string{ - "An API token is used to authenticate requests to the Fastly API. To create a token, visit", - "https://manage.fastly.com/account/personal/tokens", - "Fastly API token: ", - "Validating token...", - "Persisting configuration...", - "Configured the Fastly CLI", - "You can find your configuration file at", - }, - wantFile: ` -[cli] - last_checked = "" - remote_config = "" - ttl = "" - version = "" - -[fastly] - api_endpoint = "https://api.fastly.com" - -[language] - - [language.rust] - fastly_sys_constraint = "" - rustup_constraint = "" - toolchain_version = "" - wasm_wasi_target = "" - -[legacy] - email = "" - token = "" - -[starter-kits] - -[user] - email = "test@example.com" - token = "1234" -`, - }, - { - name: "token from environment", - args: []string{"configure"}, - env: config.Environment{Token: "hello"}, - api: mock.API{ - GetTokenSelfFn: goodToken, - GetUserFn: goodUser, - }, - wantOutput: []string{ - "Fastly API token provided via FASTLY_API_TOKEN", - "Validating token...", - "Persisting configuration...", - "Configured the Fastly CLI", - "You can find your configuration file at", - }, - wantFile: ` -[cli] - last_checked = "" - remote_config = "" - ttl = "" - version = "" - -[fastly] - api_endpoint = "https://api.fastly.com" - -[language] - - [language.rust] - fastly_sys_constraint = "" - rustup_constraint = "" - toolchain_version = "" - wasm_wasi_target = "" - -[legacy] - email = "" - token = "" - -[starter-kits] - -[user] - email = "test@example.com" - token = "hello" -`, - }, - { - name: "token already in file should trigger interactive input", - args: []string{"configure"}, - configFileData: "token = \"old_token\"", - stdin: "new_token\n", - api: mock.API{ - GetTokenSelfFn: goodToken, - GetUserFn: goodUser, - }, - wantOutput: []string{ - "An API token is used to authenticate requests to the Fastly API. To create a token, visit", - "https://manage.fastly.com/account/personal/tokens", - "Fastly API token: ", - "Validating token...", - "Persisting configuration...", - "Configured the Fastly CLI", - "You can find your configuration file at", - }, - wantFile: ` -[cli] - last_checked = "" - remote_config = "" - ttl = "" - version = "" - -[fastly] - api_endpoint = "https://api.fastly.com" - -[language] - - [language.rust] - fastly_sys_constraint = "" - rustup_constraint = "" - toolchain_version = "" - wasm_wasi_target = "" - -[legacy] - email = "" - token = "" - -[starter-kits] - -[user] - email = "test@example.com" - token = "new_token" -`, - }, - { - name: "invalid token", - args: []string{"configure", "--token=abcdef"}, - api: mock.API{ - GetTokenSelfFn: badToken, - GetUserFn: badUser, - }, - wantOutput: []string{ - "Fastly API token provided via --token", - "Validating token...", - }, - wantError: "error validating token: bad token", - }, - } { - t.Run(testcase.name, func(t *testing.T) { - configFilePath := testutil.MakeTempFile(t, testcase.configFileData) - defer os.RemoveAll(configFilePath) - - var ( - args = testcase.args - env = testcase.env - file = testcase.file - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = strings.NewReader(testcase.stdin) - out bytes.Buffer - ) - err := app.Run(args, env, file, configFilePath, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - for _, s := range testcase.wantOutput { - testutil.AssertStringContains(t, out.String(), s) - } - if testcase.wantError == "" { - p, err := os.ReadFile(configFilePath) - testutil.AssertNoError(t, err) - testutil.AssertString(t, testcase.wantFile, string(p)) - } - }) - } -} diff --git a/pkg/configure/doc.go b/pkg/configure/doc.go deleted file mode 100644 index ea87a6aee..000000000 --- a/pkg/configure/doc.go +++ /dev/null @@ -1,3 +0,0 @@ -// Package configure contains commands to inspect and manipulate the CLI -// global confuguration. -package configure diff --git a/pkg/configure/root.go b/pkg/configure/root.go deleted file mode 100644 index d9b5ea56a..000000000 --- a/pkg/configure/root.go +++ /dev/null @@ -1,148 +0,0 @@ -package configure - -import ( - "errors" - "fmt" - "io" - "os" - "path/filepath" - "strings" - - "github.com/fastly/cli/pkg/api" - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// APIClientFactory allows the configure command to regenerate the global Fastly -// API client when a new token is provided, in order to validate that token. -// It's a redeclaration of the app.APIClientFactory to avoid an import loop. -type APIClientFactory func(token, endpoint string) (api.Interface, error) - -// RootCommand is the parent command for all subcommands in this package. -// It should be installed under the primary root command. -type RootCommand struct { - common.Base - configFilePath string - clientFactory APIClientFactory -} - -// NewRootCommand returns a new command registered in the parent. -func NewRootCommand(parent common.Registerer, configFilePath string, cf APIClientFactory, globals *config.Data) *RootCommand { - var c RootCommand - c.Globals = globals - c.CmdClause = parent.Command("configure", "Configure the Fastly CLI") - c.configFilePath = configFilePath - c.clientFactory = cf - return &c -} - -// Exec implements the command interface. -func (c *RootCommand) Exec(in io.Reader, out io.Writer) (err error) { - // Get the endpoint provided by the user, if it was explicitly provided. If - // it wasn't provided use default. - endpoint, source := c.Globals.Endpoint() - switch source { // TODO(pb): this can be duplicate output if --verbose is passed - case config.SourceFlag: - text.Output(out, "Fastly API endpoint (via --endpoint): %s", endpoint) - case config.SourceEnvironment: - text.Output(out, "Fastly API endpoint (via %s): %s", config.EnvVarEndpoint, endpoint) - } - - // Get the token provided by the user, if it was explicitly provided. If it - // wasn't provided, or if it only exists in the config file, take it - // interactively. - token, source := c.Globals.Token() - switch source { // TODO(pb): this can be duplicate output if --verbose is passed - case config.SourceFlag: - text.Output(out, "Fastly API token provided via --token") - case config.SourceEnvironment: - text.Output(out, "Fastly API token provided via %s", config.EnvVarToken) - default: - text.Output(out, ` - An API token is used to authenticate requests to the Fastly API. - To create a token, visit https://manage.fastly.com/account/personal/tokens - `) - text.Break(out) - token, err = text.InputSecure(out, "Fastly API token: ", in, validateTokenNotEmpty) - if err != nil { - return err - } - text.Break(out) - } - - text.Break(out) - - progress := text.NewQuietProgress(out) - defer func() { - if err != nil { - progress.Fail() // progress.Done is handled inline - } - }() - - progress.Step("Validating token...") - - client, err := c.clientFactory(token, endpoint) - if err != nil { - return fmt.Errorf("error regenerating Fastly API client: %w", err) - } - t, err := client.GetTokenSelf() - if err != nil { - return fmt.Errorf("error validating token: %w", err) - } - user, err := client.GetUser(&fastly.GetUserInput{ - ID: t.UserID, - }) - if err != nil { - return fmt.Errorf("error fetching token user: %w", err) - } - - progress.Step("Persisting configuration...") - - // Set everything in the File struct based on provided user input. - c.Globals.File.User.Token = token - c.Globals.File.User.Email = user.Login - c.Globals.File.Fastly.APIEndpoint = endpoint - - // Make sure the config file directory exists. - dir := filepath.Dir(c.configFilePath) - fi, err := os.Stat(dir) - switch { - case err == nil && fi.IsDir(): - // good - case err == nil && !fi.IsDir(): - return fmt.Errorf("config file path %s isn't a directory", dir) - case err != nil && os.IsNotExist(err): - if err := os.MkdirAll(dir, config.DirectoryPermissions); err != nil { - return fmt.Errorf("error creating config file directory: %w", err) - } - } - - // Write the file data to disk. - if err := c.Globals.File.Write(c.configFilePath); err != nil { - return fmt.Errorf("error saving config file: %w", err) - } - - // Escape any spaces in filepath before output. - filePath := strings.ReplaceAll(c.configFilePath, " ", `\ `) - - progress.Done() - text.Break(out) - text.Description(out, "You can find your configuration file at", filePath) - - text.Success(out, "Configured the Fastly CLI") - - return nil -} - -func validateTokenNotEmpty(s string) error { - if s == "" { - return ErrEmptyToken - } - return nil -} - -// ErrEmptyToken is returned when a user tries to supply an emtpy string as a -// token in the configure command. -var ErrEmptyToken = errors.New("token cannot be empty") diff --git a/pkg/debug/debug.go b/pkg/debug/debug.go new file mode 100644 index 000000000..fe83977ca --- /dev/null +++ b/pkg/debug/debug.go @@ -0,0 +1,36 @@ +package debug + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httputil" +) + +// PrintStruct pretty prints the given struct. +func PrintStruct(v any) error { + b, err := json.MarshalIndent(v, "", " ") + if err == nil { + fmt.Println(string(b)) + } + return err +} + +// DumpHTTPRequest dumps the HTTP network request if --debug-mode is set. +func DumpHTTPRequest(r *http.Request) { + req := r.Clone(context.Background()) + if req.Header.Get("Fastly-Key") != "" { + req.Header.Set("Fastly-Key", "REDACTED") + } + dump, _ := httputil.DumpRequest(r, true) + fmt.Printf("\n\nhttp.Request (dump): %q\n\n", dump) +} + +// DumpHTTPResponse dumps the HTTP network response if --debug-mode is set. +func DumpHTTPResponse(r *http.Response) { + if r != nil { + dump, _ := httputil.DumpResponse(r, true) + fmt.Printf("\n\nhttp.Response (dump): %q\n\n", dump) + } +} diff --git a/pkg/debug/doc.go b/pkg/debug/doc.go new file mode 100644 index 000000000..d82632fb5 --- /dev/null +++ b/pkg/debug/doc.go @@ -0,0 +1,2 @@ +// Package debug contains functions to ease development of the Fastly CLI. +package debug diff --git a/pkg/domain/create.go b/pkg/domain/create.go deleted file mode 100644 index 50f865d1a..000000000 --- a/pkg/domain/create.go +++ /dev/null @@ -1,50 +0,0 @@ -package domain - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// CreateCommand calls the Fastly API to create domains. -type CreateCommand struct { - common.Base - manifest manifest.Data - Input fastly.CreateDomainInput -} - -// NewCreateCommand returns a usable command registered under the parent. -func NewCreateCommand(parent common.Registerer, globals *config.Data) *CreateCommand { - var c CreateCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("create", "Create a domain on a Fastly service version").Alias("add") - c.CmdClause.Flag("name", "Domain name").Short('n').Required().StringVar(&c.Input.Name) - c.CmdClause.Flag("comment", "A descriptive note").StringVar(&c.Input.Comment) - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Input.ServiceVersion) - return &c -} - -// Exec invokes the application logic for the command. -func (c *CreateCommand) Exec(in io.Reader, out io.Writer) error { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - c.Input.ServiceID = serviceID - - d, err := c.Globals.Client.CreateDomain(&c.Input) - if err != nil { - return err - } - - text.Success(out, "Created domain %s (service %s version %d)", d.Name, d.ServiceID, d.ServiceVersion) - return nil -} diff --git a/pkg/domain/delete.go b/pkg/domain/delete.go deleted file mode 100644 index 0577cf52f..000000000 --- a/pkg/domain/delete.go +++ /dev/null @@ -1,48 +0,0 @@ -package domain - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// DeleteCommand calls the Fastly API to delete domains. -type DeleteCommand struct { - common.Base - manifest manifest.Data - Input fastly.DeleteDomainInput -} - -// NewDeleteCommand returns a usable command registered under the parent. -func NewDeleteCommand(parent common.Registerer, globals *config.Data) *DeleteCommand { - var c DeleteCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("delete", "Delete a domain on a Fastly service version").Alias("remove") - c.CmdClause.Flag("name", "Domain name").Short('n').Required().StringVar(&c.Input.Name) - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Input.ServiceVersion) - return &c -} - -// Exec invokes the application logic for the command. -func (c *DeleteCommand) Exec(in io.Reader, out io.Writer) error { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - c.Input.ServiceID = serviceID - - if err := c.Globals.Client.DeleteDomain(&c.Input); err != nil { - return err - } - - text.Success(out, "Deleted domain %s (service %s version %d)", c.Input.Name, c.Input.ServiceID, c.Input.ServiceVersion) - return nil -} diff --git a/pkg/domain/describe.go b/pkg/domain/describe.go deleted file mode 100644 index adcd8b1ab..000000000 --- a/pkg/domain/describe.go +++ /dev/null @@ -1,53 +0,0 @@ -package domain - -import ( - "fmt" - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/go-fastly/v3/fastly" -) - -// DescribeCommand calls the Fastly API to describe a domain. -type DescribeCommand struct { - common.Base - manifest manifest.Data - Input fastly.GetDomainInput -} - -// NewDescribeCommand returns a usable command registered under the parent. -func NewDescribeCommand(parent common.Registerer, globals *config.Data) *DescribeCommand { - var c DescribeCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("describe", "Show detailed information about a domain on a Fastly service version").Alias("get") - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Input.ServiceVersion) - c.CmdClause.Flag("name", "Name of domain").Short('n').Required().StringVar(&c.Input.Name) - return &c -} - -// Exec invokes the application logic for the command. -func (c *DescribeCommand) Exec(in io.Reader, out io.Writer) error { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - c.Input.ServiceID = serviceID - - domain, err := c.Globals.Client.GetDomain(&c.Input) - if err != nil { - return err - } - - fmt.Fprintf(out, "Service ID: %s\n", domain.ServiceID) - fmt.Fprintf(out, "Version: %d\n", domain.ServiceVersion) - fmt.Fprintf(out, "Name: %s\n", domain.Name) - fmt.Fprintf(out, "Comment: %v\n", domain.Comment) - - return nil -} diff --git a/pkg/domain/domain_test.go b/pkg/domain/domain_test.go deleted file mode 100644 index e9b6ed7e9..000000000 --- a/pkg/domain/domain_test.go +++ /dev/null @@ -1,342 +0,0 @@ -package domain_test - -import ( - "bytes" - "errors" - "io" - "net/http" - "strings" - "testing" - - "github.com/fastly/cli/pkg/app" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/mock" - "github.com/fastly/cli/pkg/testutil" - "github.com/fastly/cli/pkg/update" - "github.com/fastly/go-fastly/v3/fastly" -) - -func TestDomainCreate(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"domain", "create", "--version", "1", "--service-id", "123"}, - api: mock.API{CreateDomainFn: createDomainOK}, - wantError: "error parsing arguments: required flag --name not provided", - }, - { - args: []string{"domain", "create", "--service-id", "123", "--version", "1", "--name", "www.test.com"}, - api: mock.API{CreateDomainFn: createDomainOK}, - wantOutput: "Created domain www.test.com (service 123 version 1)", - }, - { - args: []string{"domain", "create", "--service-id", "123", "--version", "1", "--name", "www.test.com"}, - api: mock.API{CreateDomainFn: createDomainError}, - wantError: errTest.Error(), - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertStringContains(t, out.String(), testcase.wantOutput) - }) - } -} - -func TestDomainList(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"domain", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListDomainsFn: listDomainsOK}, - wantOutput: listDomainsShortOutput, - }, - { - args: []string{"domain", "list", "--service-id", "123", "--version", "1", "--verbose"}, - api: mock.API{ListDomainsFn: listDomainsOK}, - wantOutput: listDomainsVerboseOutput, - }, - { - args: []string{"domain", "list", "--service-id", "123", "--version", "1", "-v"}, - api: mock.API{ListDomainsFn: listDomainsOK}, - wantOutput: listDomainsVerboseOutput, - }, - { - args: []string{"domain", "--verbose", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListDomainsFn: listDomainsOK}, - wantOutput: listDomainsVerboseOutput, - }, - { - args: []string{"-v", "domain", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListDomainsFn: listDomainsOK}, - wantOutput: listDomainsVerboseOutput, - }, - { - args: []string{"domain", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListDomainsFn: listDomainsError}, - wantError: errTest.Error(), - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertString(t, testcase.wantOutput, out.String()) - }) - } -} - -func TestDomainDescribe(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"domain", "describe", "--service-id", "123", "--version", "1"}, - api: mock.API{GetDomainFn: getDomainOK}, - wantError: "error parsing arguments: required flag --name not provided", - }, - { - args: []string{"domain", "describe", "--service-id", "123", "--version", "1", "--name", "www.test.com"}, - api: mock.API{GetDomainFn: getDomainError}, - wantError: errTest.Error(), - }, - { - args: []string{"domain", "describe", "--service-id", "123", "--version", "1", "--name", "www.test.com"}, - api: mock.API{GetDomainFn: getDomainOK}, - wantOutput: describeDomainOutput, - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertString(t, testcase.wantOutput, out.String()) - }) - } -} - -func TestDomainUpdate(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"domain", "update", "--service-id", "123", "--version", "2", "--new-name", "www.test.com", "--comment", ""}, - api: mock.API{UpdateDomainFn: updateDomainOK}, - wantError: "error parsing arguments: required flag --name not provided", - }, - { - args: []string{"domain", "update", "--service-id", "123", "--version", "2", "--name", "www.test.com"}, - api: mock.API{UpdateDomainFn: updateDomainOK}, - wantError: "error parsing arguments: must provide either --new-name or --comment to update domain", - }, - { - args: []string{"domain", "update", "--service-id", "123", "--version", "1", "--name", "www.test.com", "--new-name", "www.example.com"}, - api: mock.API{UpdateDomainFn: updateDomainError}, - wantError: errTest.Error(), - }, - { - args: []string{"domain", "update", "--service-id", "123", "--version", "1", "--name", "www.test.com", "--new-name", "www.example.com"}, - api: mock.API{UpdateDomainFn: updateDomainOK}, - wantOutput: "Updated domain www.example.com (service 123 version 1)", - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertStringContains(t, out.String(), testcase.wantOutput) - }) - } -} - -func TestDomainDelete(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"domain", "delete", "--service-id", "123", "--version", "1"}, - api: mock.API{DeleteDomainFn: deleteDomainOK}, - wantError: "error parsing arguments: required flag --name not provided", - }, - { - args: []string{"domain", "delete", "--service-id", "123", "--version", "1", "--name", "www.test.com"}, - api: mock.API{DeleteDomainFn: deleteDomainError}, - wantError: errTest.Error(), - }, - { - args: []string{"domain", "delete", "--service-id", "123", "--version", "1", "--name", "www.test.com"}, - api: mock.API{DeleteDomainFn: deleteDomainOK}, - wantOutput: "Deleted domain www.test.com (service 123 version 1)", - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertStringContains(t, out.String(), testcase.wantOutput) - }) - } -} - -var errTest = errors.New("fixture error") - -func createDomainOK(i *fastly.CreateDomainInput) (*fastly.Domain, error) { - return &fastly.Domain{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: i.Name, - Comment: i.Comment, - }, nil -} - -func createDomainError(i *fastly.CreateDomainInput) (*fastly.Domain, error) { - return nil, errTest -} - -func listDomainsOK(i *fastly.ListDomainsInput) ([]*fastly.Domain, error) { - return []*fastly.Domain{ - { - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "www.test.com", - Comment: "test", - }, - { - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "www.example.com", - Comment: "example", - }, - }, nil -} - -func listDomainsError(i *fastly.ListDomainsInput) ([]*fastly.Domain, error) { - return nil, errTest -} - -var listDomainsShortOutput = strings.TrimSpace(` -SERVICE VERSION NAME COMMENT -123 1 www.test.com test -123 1 www.example.com example -`) + "\n" - -var listDomainsVerboseOutput = strings.TrimSpace(` -Fastly API token not provided -Fastly API endpoint: https://api.fastly.com -Service ID: 123 -Version: 1 - Domain 1/2 - Name: www.test.com - Comment: test - Domain 2/2 - Name: www.example.com - Comment: example -`) + "\n\n" - -func getDomainOK(i *fastly.GetDomainInput) (*fastly.Domain, error) { - return &fastly.Domain{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: i.Name, - Comment: "test", - }, nil -} - -func getDomainError(i *fastly.GetDomainInput) (*fastly.Domain, error) { - return nil, errTest -} - -var describeDomainOutput = strings.TrimSpace(` -Service ID: 123 -Version: 1 -Name: www.test.com -Comment: test -`) + "\n" - -func updateDomainOK(i *fastly.UpdateDomainInput) (*fastly.Domain, error) { - return &fastly.Domain{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: *i.NewName, - }, nil -} - -func updateDomainError(i *fastly.UpdateDomainInput) (*fastly.Domain, error) { - return nil, errTest -} - -func deleteDomainOK(i *fastly.DeleteDomainInput) error { - return nil -} - -func deleteDomainError(i *fastly.DeleteDomainInput) error { - return errTest -} diff --git a/pkg/domain/list.go b/pkg/domain/list.go deleted file mode 100644 index d8678be84..000000000 --- a/pkg/domain/list.go +++ /dev/null @@ -1,67 +0,0 @@ -package domain - -import ( - "fmt" - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// ListCommand calls the Fastly API to list domains. -type ListCommand struct { - common.Base - manifest manifest.Data - Input fastly.ListDomainsInput -} - -// NewListCommand returns a usable command registered under the parent. -func NewListCommand(parent common.Registerer, globals *config.Data) *ListCommand { - var c ListCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("list", "List domains on a Fastly service version") - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Input.ServiceVersion) - return &c -} - -// Exec invokes the application logic for the command. -func (c *ListCommand) Exec(in io.Reader, out io.Writer) error { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - c.Input.ServiceID = serviceID - - domains, err := c.Globals.Client.ListDomains(&c.Input) - if err != nil { - return err - } - - if !c.Globals.Verbose() { - tw := text.NewTable(out) - tw.AddHeader("SERVICE", "VERSION", "NAME", "COMMENT") - for _, domain := range domains { - tw.AddLine(domain.ServiceID, domain.ServiceVersion, domain.Name, domain.Comment) - } - tw.Print() - return nil - } - - fmt.Fprintf(out, "Service ID: %s\n", c.Input.ServiceID) - fmt.Fprintf(out, "Version: %d\n", c.Input.ServiceVersion) - for i, domain := range domains { - fmt.Fprintf(out, "\tDomain %d/%d\n", i+1, len(domains)) - fmt.Fprintf(out, "\t\tName: %s\n", domain.Name) - fmt.Fprintf(out, "\t\tComment: %v\n", domain.Comment) - } - fmt.Fprintln(out) - - return nil -} diff --git a/pkg/domain/root.go b/pkg/domain/root.go deleted file mode 100644 index a3b8a2318..000000000 --- a/pkg/domain/root.go +++ /dev/null @@ -1,28 +0,0 @@ -package domain - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/config" -) - -// RootCommand is the parent command for all subcommands in this package. -// It should be installed under the primary root command. -type RootCommand struct { - common.Base - // no flags -} - -// NewRootCommand returns a new command registered in the parent. -func NewRootCommand(parent common.Registerer, globals *config.Data) *RootCommand { - var c RootCommand - c.Globals = globals - c.CmdClause = parent.Command("domain", "Manipulate Fastly service version domains") - return &c -} - -// Exec implements the command interface. -func (c *RootCommand) Exec(in io.Reader, out io.Writer) error { - panic("unreachable") -} diff --git a/pkg/domain/update.go b/pkg/domain/update.go deleted file mode 100644 index 42445621b..000000000 --- a/pkg/domain/update.go +++ /dev/null @@ -1,65 +0,0 @@ -package domain - -import ( - "fmt" - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// UpdateCommand calls the Fastly API to update domains. -type UpdateCommand struct { - common.Base - manifest manifest.Data - input fastly.UpdateDomainInput - - NewName common.OptionalString - Comment common.OptionalString -} - -// NewUpdateCommand returns a usable command registered under the parent. -func NewUpdateCommand(parent common.Registerer, globals *config.Data) *UpdateCommand { - var c UpdateCommand - c.Globals = globals - c.CmdClause = parent.Command("update", "Update a domain on a Fastly service version") - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.input.ServiceVersion) - c.CmdClause.Flag("name", "Domain name").Short('n').Required().StringVar(&c.input.Name) - c.CmdClause.Flag("new-name", "New domain name").Action(c.NewName.Set).StringVar(&c.NewName.Value) - c.CmdClause.Flag("comment", "A descriptive note").Action(c.Comment.Set).StringVar(&c.Comment.Value) - return &c -} - -// Exec invokes the application logic for the command. -func (c *UpdateCommand) Exec(in io.Reader, out io.Writer) error { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - c.input.ServiceID = serviceID - - // If neither arguments are provided, error with useful message. - if !c.NewName.WasSet && !c.Comment.WasSet { - return fmt.Errorf("error parsing arguments: must provide either --new-name or --comment to update domain") - } - - if c.NewName.WasSet { - c.input.NewName = fastly.String(c.NewName.Value) - } - if c.Comment.WasSet { - c.input.Comment = fastly.String(c.Comment.Value) - } - - d, err := c.Globals.Client.UpdateDomain(&c.input) - if err != nil { - return err - } - - text.Success(out, "Updated domain %s (service %s version %d)", d.Name, d.ServiceID, d.ServiceVersion) - return nil -} diff --git a/pkg/edgedictionary/create.go b/pkg/edgedictionary/create.go deleted file mode 100644 index 1d3af4a1b..000000000 --- a/pkg/edgedictionary/create.go +++ /dev/null @@ -1,66 +0,0 @@ -package edgedictionary - -import ( - "io" - "strconv" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// CreateCommand calls the Fastly API to create a service. -type CreateCommand struct { - common.Base - manifest manifest.Data - Input fastly.CreateDictionaryInput - - writeOnly common.OptionalString -} - -// NewCreateCommand returns a usable command registered under the parent. -func NewCreateCommand(parent common.Registerer, globals *config.Data) *CreateCommand { - var c CreateCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("create", "Create a Fastly edge dictionary on a Fastly service version") - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Input.ServiceVersion) - c.CmdClause.Flag("name", "Name of Dictionary").Short('n').Required().StringVar(&c.Input.Name) - c.CmdClause.Flag("write-only", "Whether to mark this dictionary as write-only. Can be true or false (defaults to false)").Action(c.writeOnly.Set).StringVar(&c.writeOnly.Value) - return &c -} - -// Exec invokes the application logic for the command. -func (c *CreateCommand) Exec(in io.Reader, out io.Writer) error { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - c.Input.ServiceID = serviceID - - if c.writeOnly.WasSet { - writeOnly, err := strconv.ParseBool(c.writeOnly.Value) - if err != nil { - return err - } - c.Input.WriteOnly = fastly.Compatibool(writeOnly) - } - - d, err := c.Globals.Client.CreateDictionary(&c.Input) - if err != nil { - return err - } - - var writeOnlyOutput string - if d.WriteOnly { - writeOnlyOutput = "as write-only " - } - - text.Success(out, "Created dictionary %s %s(service %s version %d)", d.Name, writeOnlyOutput, d.ServiceID, d.ServiceVersion) - return nil -} diff --git a/pkg/edgedictionary/delete.go b/pkg/edgedictionary/delete.go deleted file mode 100644 index 912ae6dd6..000000000 --- a/pkg/edgedictionary/delete.go +++ /dev/null @@ -1,49 +0,0 @@ -package edgedictionary - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// DeleteCommand calls the Fastly API to delete a service. -type DeleteCommand struct { - common.Base - manifest manifest.Data - Input fastly.DeleteDictionaryInput -} - -// NewDeleteCommand returns a usable command registered under the parent. -func NewDeleteCommand(parent common.Registerer, globals *config.Data) *DeleteCommand { - var c DeleteCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("delete", "Delete a Fastly edge dictionary from a Fastly service version") - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Input.ServiceVersion) - c.CmdClause.Flag("name", "Name of Dictionary").Short('n').Required().StringVar(&c.Input.Name) - return &c -} - -// Exec invokes the application logic for the command. -func (c *DeleteCommand) Exec(in io.Reader, out io.Writer) error { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - c.Input.ServiceID = serviceID - - err := c.Globals.Client.DeleteDictionary(&c.Input) - if err != nil { - return err - } - - text.Success(out, "Deleted dictionary %s (service %s version %d)", c.Input.Name, c.Input.ServiceID, c.Input.ServiceVersion) - return nil -} diff --git a/pkg/edgedictionary/describe.go b/pkg/edgedictionary/describe.go deleted file mode 100644 index e5d41b22b..000000000 --- a/pkg/edgedictionary/describe.go +++ /dev/null @@ -1,79 +0,0 @@ -package edgedictionary - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// DescribeCommand calls the Fastly API to describe a dictionary. -type DescribeCommand struct { - common.Base - manifest manifest.Data - Input fastly.GetDictionaryInput -} - -// NewDescribeCommand returns a usable command registered under the parent. -func NewDescribeCommand(parent common.Registerer, globals *config.Data) *DescribeCommand { - var c DescribeCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("describe", "Show detailed information about a Fastly edge dictionary").Alias("get") - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Input.ServiceVersion) - c.CmdClause.Flag("name", "Name of Dictionary").Short('n').Required().StringVar(&c.Input.Name) - return &c -} - -// Exec invokes the application logic for the command. -func (c *DescribeCommand) Exec(in io.Reader, out io.Writer) error { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - c.Input.ServiceID = serviceID - - dictionary, err := c.Globals.Client.GetDictionary(&c.Input) - if err != nil { - return err - } - - text.Output(out, "Service ID: %s", dictionary.ServiceID) - text.Output(out, "Version: %d", dictionary.ServiceVersion) - text.PrintDictionary(out, "", dictionary) - - if c.Globals.Verbose() { - infoInput := fastly.GetDictionaryInfoInput{ - ServiceID: c.Input.ServiceID, - ServiceVersion: c.Input.ServiceVersion, - ID: dictionary.ID, - } - info, err := c.Globals.Client.GetDictionaryInfo(&infoInput) - if err != nil { - return err - } - text.Output(out, "Digest: %s", info.Digest) - text.Output(out, "Item Count: %d", info.ItemCount) - - itemInput := fastly.ListDictionaryItemsInput{ - ServiceID: c.Input.ServiceID, - DictionaryID: dictionary.ID, - } - items, err := c.Globals.Client.ListDictionaryItems(&itemInput) - if err != nil { - return err - } - for i, item := range items { - text.Output(out, "Item %d/%d:", i+1, len(items)) - text.PrintDictionaryItemKV(out, " ", item) - } - } - - return nil -} diff --git a/pkg/edgedictionary/edgedictionary_test.go b/pkg/edgedictionary/edgedictionary_test.go deleted file mode 100644 index e0aa86cc6..000000000 --- a/pkg/edgedictionary/edgedictionary_test.go +++ /dev/null @@ -1,492 +0,0 @@ -package edgedictionary_test - -import ( - "bytes" - "errors" - "io" - "net/http" - "strings" - "testing" - - "github.com/fastly/cli/pkg/app" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/mock" - "github.com/fastly/cli/pkg/testutil" - "github.com/fastly/cli/pkg/update" - "github.com/fastly/go-fastly/v3/fastly" -) - -func TestDictionaryDescribe(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"dictionary", "describe", "--version", "1", "--service-id", "123"}, - api: mock.API{GetDictionaryFn: describeDictionaryOK}, - wantError: "error parsing arguments: required flag --name not provided", - }, - { - args: []string{"dictionary", "describe", "--version", "1", "--service-id", "123", "--name", "dict-1"}, - api: mock.API{GetDictionaryFn: describeDictionaryOK}, - wantOutput: describeDictionaryOutput, - }, - { - args: []string{"dictionary", "describe", "--version", "1", "--service-id", "123", "--name", "dict-1"}, - api: mock.API{GetDictionaryFn: describeDictionaryOKDeleted}, - wantOutput: describeDictionaryOutputDeleted, - }, - { - args: []string{"dictionary", "describe", "--version", "1", "--service-id", "123", "--name", "dict-1", "--verbose"}, - api: mock.API{ - GetDictionaryFn: describeDictionaryOK, - GetDictionaryInfoFn: getDictionaryInfoOK, - ListDictionaryItemsFn: listDictionaryItemsOK, - }, - wantOutput: describeDictionaryOutputVerbose, - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertString(t, testcase.wantOutput, out.String()) - }) - } -} - -func TestDictionaryCreate(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"dictionary", "create", "--version", "1", "--service-id", "123"}, - wantError: "error parsing arguments: required flag --name not provided", - }, - { - args: []string{"dictionary", "create", "--version", "1", "--service-id", "123", "--name", "denylist"}, - api: mock.API{CreateDictionaryFn: createDictionaryOK}, - wantOutput: createDictionaryOutput, - }, - { - args: []string{"dictionary", "create", "--version", "1", "--service-id", "123", "--name", "denylist", "--write-only", "true"}, - api: mock.API{CreateDictionaryFn: createDictionaryOK}, - wantOutput: createDictionaryOutputWriteOnly, - }, - { - args: []string{"dictionary", "create", "--version", "1", "--service-id", "123", "--name", "denylist", "--write-only", "fish"}, - wantError: "strconv.ParseBool: parsing \"fish\": invalid syntax", - }, - { - args: []string{"dictionary", "create", "--version", "1", "--service-id", "123", "--name", "denylist"}, - api: mock.API{CreateDictionaryFn: createDictionaryDuplicate}, - wantError: "Duplicate record", - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertString(t, testcase.wantOutput, out.String()) - }) - } -} - -func TestDeleteDictionary(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"dictionary", "delete", "--service-id", "123", "--version", "1"}, - api: mock.API{DeleteDictionaryFn: deleteDictionaryOK}, - wantError: "error parsing arguments: required flag --name not provided", - }, - { - args: []string{"dictionary", "delete", "--service-id", "123", "--version", "1", "--name", "allowlist"}, - api: mock.API{DeleteDictionaryFn: deleteDictionaryOK}, - wantOutput: deleteDictionaryOutput, - }, - { - args: []string{"dictionary", "delete", "--service-id", "123", "--version", "1", "--name", "allowlist"}, - api: mock.API{DeleteDictionaryFn: deleteDictionaryError}, - wantError: errTest.Error(), - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - versioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, versioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertString(t, testcase.wantOutput, out.String()) - }) - } -} - -func TestListDictionary(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"dictionary", "list", "--version", "1"}, - api: mock.API{ListDictionariesFn: listDictionariesOk}, - wantError: "error reading service: no service ID found", - }, - { - args: []string{"dictionary", "list", "--service-id", "123"}, - api: mock.API{DeleteDictionaryFn: deleteDictionaryOK}, - wantError: "error parsing arguments: required flag --version not provided", - }, - { - args: []string{"dictionary", "list", "--version", "1", "--service-id", "123"}, - api: mock.API{ListDictionariesFn: listDictionariesOk}, - wantOutput: listDictionariesOutput, - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - versioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, versioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertString(t, testcase.wantOutput, out.String()) - }) - } -} - -func TestUpdateDictionary(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"dictionary", "update", "--version", "1", "--name", "oldname", "--new-name", "newname"}, - wantError: "error reading service: no service ID found", - }, - { - args: []string{"dictionary", "update", "--service-id", "123", "--name", "oldname", "--new-name", "newname"}, - wantError: "error parsing arguments: required flag --version not provided", - }, - { - args: []string{"dictionary", "update", "--service-id", "123", "--version", "1", "--new-name", "newname"}, - wantError: "error parsing arguments: required flag --name not provided", - }, - { - args: []string{"dictionary", "update", "--service-id", "123", "--version", "1", "--name", "oldname"}, - wantError: "error parsing arguments: required flag --new-name or --write-only not provided", - }, - { - args: []string{"dictionary", "update", "--service-id", "123", "--version", "1", "--name", "oldname", "--new-name", "dict-1"}, - api: mock.API{UpdateDictionaryFn: updateDictionaryNameOK}, - wantOutput: updateDictionaryNameOutput, - }, - { - args: []string{"dictionary", "update", "--service-id", "123", "--version", "1", "--name", "oldname", "--new-name", "dict-1", "--write-only", "true"}, - api: mock.API{UpdateDictionaryFn: updateDictionaryNameOK}, - wantOutput: updateDictionaryNameOutput, - }, - { - args: []string{"dictionary", "update", "--service-id", "123", "--version", "1", "--name", "oldname", "--write-only", "true"}, - api: mock.API{UpdateDictionaryFn: updateDictionaryWriteOnlyOK}, - wantOutput: updateDictionaryOutput, - }, - { - args: []string{"dictionary", "update", "-v", "--service-id", "123", "--version", "1", "--name", "oldname", "--new-name", "dict-1"}, - api: mock.API{UpdateDictionaryFn: updateDictionaryNameOK}, - wantOutput: updateDictionaryOutputVerbose, - }, - { - args: []string{"dictionary", "update", "--service-id", "123", "--version", "1", "--name", "oldname", "--new-name", "dict-1"}, - api: mock.API{UpdateDictionaryFn: updateDictionaryError}, - wantError: errTest.Error(), - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - versioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, versioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertString(t, testcase.wantOutput, out.String()) - }) - } -} - -func describeDictionaryOK(i *fastly.GetDictionaryInput) (*fastly.Dictionary, error) { - return &fastly.Dictionary{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: i.Name, - CreatedAt: testutil.MustParseTimeRFC3339("2001-02-03T04:05:06Z"), - WriteOnly: false, - ID: "456", - UpdatedAt: testutil.MustParseTimeRFC3339("2001-02-03T04:05:07Z"), - }, nil -} - -func describeDictionaryOKDeleted(i *fastly.GetDictionaryInput) (*fastly.Dictionary, error) { - return &fastly.Dictionary{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: i.Name, - CreatedAt: testutil.MustParseTimeRFC3339("2001-02-03T04:05:06Z"), - WriteOnly: false, - ID: "456", - UpdatedAt: testutil.MustParseTimeRFC3339("2001-02-03T04:05:07Z"), - DeletedAt: testutil.MustParseTimeRFC3339("2001-02-03T04:05:08Z"), - }, nil -} - -func createDictionaryOK(i *fastly.CreateDictionaryInput) (*fastly.Dictionary, error) { - return &fastly.Dictionary{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: i.Name, - CreatedAt: testutil.MustParseTimeRFC3339("2001-02-03T04:05:06Z"), - WriteOnly: i.WriteOnly == true, - ID: "456", - UpdatedAt: testutil.MustParseTimeRFC3339("2001-02-03T04:05:07Z"), - }, nil -} - -// getDictionaryInfoOK mocks the response from fastly.GetDictionaryInfo, which is not otherwise used -// in the fastly-cli and will need to be updated here if that call changes. -// This function requires i.ID to equal "456" to enforce the input to this call matches the -// response to GetDictionaryInfo in describeDictionaryOK -func getDictionaryInfoOK(i *fastly.GetDictionaryInfoInput) (*fastly.DictionaryInfo, error) { - if i.ID == "456" { - return &fastly.DictionaryInfo{ - ItemCount: 2, - LastUpdated: testutil.MustParseTimeRFC3339("2001-02-03T04:05:07Z"), - Digest: "digest_hash", - }, nil - } else { - return nil, errFail - } -} - -// listDictionaryItemsOK mocks the response from fastly.ListDictionaryItems which is primarily used -// in the fastly-cli.edgedictionaryitem package and will need to be updated here if that call changes -func listDictionaryItemsOK(i *fastly.ListDictionaryItemsInput) ([]*fastly.DictionaryItem, error) { - return []*fastly.DictionaryItem{ - { - ServiceID: i.ServiceID, - DictionaryID: i.DictionaryID, - ItemKey: "foo", - ItemValue: "bar", - CreatedAt: testutil.MustParseTimeRFC3339("2001-02-03T04:05:06Z"), - UpdatedAt: testutil.MustParseTimeRFC3339("2001-02-03T04:05:07Z"), - }, - { - ServiceID: i.ServiceID, - DictionaryID: i.DictionaryID, - ItemKey: "baz", - ItemValue: "bear", - CreatedAt: testutil.MustParseTimeRFC3339("2001-02-03T04:05:06Z"), - UpdatedAt: testutil.MustParseTimeRFC3339("2001-02-03T04:05:07Z"), - DeletedAt: testutil.MustParseTimeRFC3339("2001-02-03T04:06:08Z"), - }, - }, nil -} - -func createDictionaryDuplicate(*fastly.CreateDictionaryInput) (*fastly.Dictionary, error) { - return nil, errors.New("Duplicate record") -} - -func deleteDictionaryOK(*fastly.DeleteDictionaryInput) error { - return nil -} - -func deleteDictionaryError(*fastly.DeleteDictionaryInput) error { - return errTest -} - -func listDictionariesOk(i *fastly.ListDictionariesInput) ([]*fastly.Dictionary, error) { - return []*fastly.Dictionary{ - { - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "dict-1", - CreatedAt: testutil.MustParseTimeRFC3339("2001-02-03T04:05:06Z"), - WriteOnly: false, - ID: "456", - UpdatedAt: testutil.MustParseTimeRFC3339("2001-02-03T04:05:07Z"), - }, - { - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "dict-2", - CreatedAt: testutil.MustParseTimeRFC3339("2001-02-03T04:05:06Z"), - WriteOnly: false, - ID: "456", - UpdatedAt: testutil.MustParseTimeRFC3339("2001-02-03T04:05:07Z"), - }, - }, nil -} - -func updateDictionaryNameOK(i *fastly.UpdateDictionaryInput) (*fastly.Dictionary, error) { - return &fastly.Dictionary{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: *i.NewName, - CreatedAt: testutil.MustParseTimeRFC3339("2001-02-03T04:05:06Z"), - WriteOnly: cbPtrIsTrue(i.WriteOnly), - ID: "456", - UpdatedAt: testutil.MustParseTimeRFC3339("2001-02-03T04:05:07Z"), - }, nil -} - -func updateDictionaryWriteOnlyOK(i *fastly.UpdateDictionaryInput) (*fastly.Dictionary, error) { - return &fastly.Dictionary{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: i.Name, - CreatedAt: testutil.MustParseTimeRFC3339("2001-02-03T04:05:06Z"), - WriteOnly: cbPtrIsTrue(i.WriteOnly), - ID: "456", - UpdatedAt: testutil.MustParseTimeRFC3339("2001-02-03T04:05:07Z"), - }, nil -} - -func cbPtrIsTrue(cb *fastly.Compatibool) bool { - if cb != nil { - return *cb == true - } - return false -} - -func updateDictionaryError(i *fastly.UpdateDictionaryInput) (*fastly.Dictionary, error) { - return nil, errTest -} - -var errTest = errors.New("an expected error ocurred") -var errFail = errors.New("this error should not be returned and indicates a failure in the code") - -var createDictionaryOutput = "\nSUCCESS: Created dictionary denylist (service 123 version 1)\n" -var createDictionaryOutputWriteOnly = "\nSUCCESS: Created dictionary denylist as write-only (service 123 version 1)\n" -var deleteDictionaryOutput = "\nSUCCESS: Deleted dictionary allowlist (service 123 version 1)\n" -var updateDictionaryOutput = "\nSUCCESS: Updated dictionary oldname (service 123 version 1)\n" -var updateDictionaryNameOutput = "\nSUCCESS: Updated dictionary dict-1 (service 123 version 1)\n" - -var updateDictionaryOutputVerbose = strings.Join( - []string{ - "Fastly API token not provided", - "Fastly API endpoint: https://api.fastly.com", - "", - strings.TrimSpace(updateDictionaryNameOutput), - describeDictionaryOutput, - }, - "\n") - -var describeDictionaryOutput = strings.TrimSpace(` -Service ID: 123 -Version: 1 -ID: 456 -Name: dict-1 -Write Only: false -Created (UTC): 2001-02-03 04:05 -Last edited (UTC): 2001-02-03 04:05 -`) + "\n" - -var describeDictionaryOutputDeleted = strings.TrimSpace(` -Service ID: 123 -Version: 1 -ID: 456 -Name: dict-1 -Write Only: false -Created (UTC): 2001-02-03 04:05 -Last edited (UTC): 2001-02-03 04:05 -Deleted (UTC): 2001-02-03 04:05 -`) + "\n" - -var describeDictionaryOutputVerbose = strings.TrimSpace(` -Fastly API token not provided -Fastly API endpoint: https://api.fastly.com -Service ID: 123 -Version: 1 -ID: 456 -Name: dict-1 -Write Only: false -Created (UTC): 2001-02-03 04:05 -Last edited (UTC): 2001-02-03 04:05 -Digest: digest_hash -Item Count: 2 -Item 1/2: - Item Key: foo - Item Value: bar -Item 2/2: - Item Key: baz - Item Value: bear -`) + "\n" - -var listDictionariesOutput = strings.TrimSpace(` -Service ID: 123 -Version: 1 -ID: 456 -Name: dict-1 -Write Only: false -Created (UTC): 2001-02-03 04:05 -Last edited (UTC): 2001-02-03 04:05 -ID: 456 -Name: dict-2 -Write Only: false -Created (UTC): 2001-02-03 04:05 -Last edited (UTC): 2001-02-03 04:05 -`) + "\n" diff --git a/pkg/edgedictionary/list.go b/pkg/edgedictionary/list.go deleted file mode 100644 index db3345b8a..000000000 --- a/pkg/edgedictionary/list.go +++ /dev/null @@ -1,53 +0,0 @@ -package edgedictionary - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// ListCommand calls the Fastly API to list dictionaries -type ListCommand struct { - common.Base - manifest manifest.Data - Input fastly.ListDictionariesInput -} - -// NewListCommand returns a usable command registered under the parent -func NewListCommand(parent common.Registerer, globals *config.Data) *ListCommand { - var c ListCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("list", "List all dictionaries on a Fastly service version") - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Input.ServiceVersion) - return &c -} - -// Exec invokes the application logic for the command. -func (c *ListCommand) Exec(in io.Reader, out io.Writer) error { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - c.Input.ServiceID = serviceID - - dictionaries, err := c.Globals.Client.ListDictionaries(&c.Input) - if err != nil { - return err - } - - text.Output(out, "Service ID: %s", serviceID) - text.Output(out, "Version: %d", c.Input.ServiceVersion) - for _, dictionary := range dictionaries { - text.PrintDictionary(out, "", dictionary) - } - - return nil -} diff --git a/pkg/edgedictionary/root.go b/pkg/edgedictionary/root.go deleted file mode 100644 index a611e94f3..000000000 --- a/pkg/edgedictionary/root.go +++ /dev/null @@ -1,28 +0,0 @@ -package edgedictionary - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/config" -) - -// RootCommand is the parent command for all subcommands in this package. -// It should be installed under the primary root command. -type RootCommand struct { - common.Base - // no flags -} - -// NewRootCommand returns a new command registered in the parent. -func NewRootCommand(parent common.Registerer, globals *config.Data) *RootCommand { - var c RootCommand - c.Globals = globals - c.CmdClause = parent.Command("dictionary", "Manipulate Fastly edge dictionaries") - return &c -} - -// Exec implements the command interface. -func (c *RootCommand) Exec(in io.Reader, out io.Writer) error { - panic("unreachable") -} diff --git a/pkg/edgedictionary/update.go b/pkg/edgedictionary/update.go deleted file mode 100644 index 65642a8f2..000000000 --- a/pkg/edgedictionary/update.go +++ /dev/null @@ -1,79 +0,0 @@ -package edgedictionary - -import ( - "fmt" - "io" - "strconv" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// UpdateCommand calls the Fastly API to update a dictionary. -type UpdateCommand struct { - common.Base - manifest manifest.Data - input fastly.UpdateDictionaryInput - - newname common.OptionalString - writeOnly common.OptionalString -} - -// NewUpdateCommand returns a usable command registered under the parent. -func NewUpdateCommand(parent common.Registerer, globals *config.Data) *UpdateCommand { - var c UpdateCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("update", "Update name of dictionary on a Fastly service version").Alias("get") - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.input.ServiceVersion) - c.CmdClause.Flag("name", "Old name of Dictionary").Short('n').Required().StringVar(&c.input.Name) - c.CmdClause.Flag("new-name", "New name of Dictionary").Action(c.newname.Set).StringVar(&c.newname.Value) - c.CmdClause.Flag("write-only", "Whether to mark this dictionary as write-only. Can be true or false (defaults to false)").Action(c.writeOnly.Set).StringVar(&c.writeOnly.Value) - return &c -} - -// Exec invokes the application logic for the command. -func (c *UpdateCommand) Exec(in io.Reader, out io.Writer) error { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - c.input.ServiceID = serviceID - - if !c.newname.WasSet && !c.writeOnly.WasSet { - return errors.RemediationError{Inner: fmt.Errorf("error parsing arguments: required flag --new-name or --write-only not provided"), Remediation: "To fix this error, provide at least one of the aforementioned flags"} - } - - if c.newname.WasSet { - c.input.NewName = &c.newname.Value - } - - if c.writeOnly.WasSet { - writeOnly, err := strconv.ParseBool(c.writeOnly.Value) - if err != nil { - return err - } - c.input.WriteOnly = fastly.CBool(writeOnly) - } - - d, err := c.Globals.Client.UpdateDictionary(&c.input) - if err != nil { - return err - } - - text.Success(out, "Updated dictionary %s (service %s version %d)", d.Name, d.ServiceID, d.ServiceVersion) - - if c.Globals.Verbose() { - text.Output(out, "Service ID: %s", d.ServiceID) - text.Output(out, "Version: %d", d.ServiceVersion) - text.PrintDictionary(out, "", d) - } - - return nil -} diff --git a/pkg/edgedictionaryitem/batch.go b/pkg/edgedictionaryitem/batch.go deleted file mode 100644 index 46878916a..000000000 --- a/pkg/edgedictionaryitem/batch.go +++ /dev/null @@ -1,73 +0,0 @@ -package edgedictionaryitem - -import ( - "encoding/json" - "fmt" - "io" - "os" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// BatchCommand calls the Fastly API to batch update a dictionary. -type BatchCommand struct { - common.Base - manifest manifest.Data - Input fastly.BatchModifyDictionaryItemsInput - - file common.OptionalString -} - -// NewBatchCommand returns a usable command registered under the parent. -func NewBatchCommand(parent common.Registerer, globals *config.Data) *BatchCommand { - var c BatchCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("batchmodify", "Update multiple items in a Fastly edge dictionary") - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("dictionary-id", "Dictionary ID").Required().StringVar(&c.Input.DictionaryID) - c.CmdClause.Flag("file", "Batch update json file").Required().Action(c.file.Set).StringVar(&c.file.Value) - return &c -} - -// Exec invokes the application logic for the command. -func (c *BatchCommand) Exec(in io.Reader, out io.Writer) error { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - c.Input.ServiceID = serviceID - - jsonFile, err := os.Open(c.file.Value) - if err != nil { - return err - } - - jsonBytes, err := io.ReadAll(jsonFile) - if err != nil { - return err - } - - err = json.Unmarshal(jsonBytes, &c.Input) - if err != nil { - return err - } - - if len(c.Input.Items) == 0 { - return fmt.Errorf("item key not found in file %s", c.file.Value) - } - - err = c.Globals.Client.BatchModifyDictionaryItems(&c.Input) - if err != nil { - return err - } - - text.Success(out, "Made %d modifications of Dictionary %s on service %s", len(c.Input.Items), c.Input.DictionaryID, c.Input.ServiceID) - return nil -} diff --git a/pkg/edgedictionaryitem/create.go b/pkg/edgedictionaryitem/create.go deleted file mode 100644 index e40f23ce6..000000000 --- a/pkg/edgedictionaryitem/create.go +++ /dev/null @@ -1,51 +0,0 @@ -package edgedictionaryitem - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// CreateCommand calls the Fastly API to create a dictionary item. -type CreateCommand struct { - common.Base - manifest manifest.Data - Input fastly.CreateDictionaryItemInput -} - -// NewCreateCommand returns a usable command registered under the parent. -func NewCreateCommand(parent common.Registerer, globals *config.Data) *CreateCommand { - var c CreateCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("create", "Create a new item on a Fastly edge dictionary") - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("dictionary-id", "Dictionary ID").Required().StringVar(&c.Input.DictionaryID) - c.CmdClause.Flag("key", "Dictionary item key").Required().StringVar(&c.Input.ItemKey) - c.CmdClause.Flag("value", "Dictionary item value").Required().StringVar(&c.Input.ItemValue) - return &c -} - -// Exec invokes the application logic for the command. -func (c *CreateCommand) Exec(in io.Reader, out io.Writer) error { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - c.Input.ServiceID = serviceID - - _, err := c.Globals.Client.CreateDictionaryItem(&c.Input) - if err != nil { - return err - } - - text.Success(out, "Created dictionary item %s (service %s, dictionary %s)", c.Input.ItemKey, c.Input.ServiceID, c.Input.DictionaryID) - - return nil -} diff --git a/pkg/edgedictionaryitem/delete.go b/pkg/edgedictionaryitem/delete.go deleted file mode 100644 index 3c9ff7b65..000000000 --- a/pkg/edgedictionaryitem/delete.go +++ /dev/null @@ -1,49 +0,0 @@ -package edgedictionaryitem - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// DeleteCommand calls the Fastly API to delete a service. -type DeleteCommand struct { - common.Base - manifest manifest.Data - Input fastly.DeleteDictionaryItemInput -} - -// NewDeleteCommand returns a usable command registered under the parent. -func NewDeleteCommand(parent common.Registerer, globals *config.Data) *DeleteCommand { - var c DeleteCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("delete", "Delete an item from a Fastly edge dictionary") - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("dictionary-id", "Dictionary ID").Required().StringVar(&c.Input.DictionaryID) - c.CmdClause.Flag("key", "Dictionary item key").Required().StringVar(&c.Input.ItemKey) - return &c -} - -// Exec invokes the application logic for the command. -func (c *DeleteCommand) Exec(in io.Reader, out io.Writer) error { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - c.Input.ServiceID = serviceID - - err := c.Globals.Client.DeleteDictionaryItem(&c.Input) - if err != nil { - return err - } - - text.Success(out, "Deleted dictionary item %s (service %s, dicitonary %s)", c.Input.ItemKey, c.Input.ServiceID, c.Input.DictionaryID) - return nil -} diff --git a/pkg/edgedictionaryitem/describe.go b/pkg/edgedictionaryitem/describe.go deleted file mode 100644 index e9c690f7a..000000000 --- a/pkg/edgedictionaryitem/describe.go +++ /dev/null @@ -1,50 +0,0 @@ -package edgedictionaryitem - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// DescribeCommand calls the Fastly API to describe a dictionary item. -type DescribeCommand struct { - common.Base - manifest manifest.Data - Input fastly.GetDictionaryItemInput -} - -// NewDescribeCommand returns a usable command registered under the parent. -func NewDescribeCommand(parent common.Registerer, globals *config.Data) *DescribeCommand { - var c DescribeCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("describe", "Show detailed information about a Fastly edge dictionary item").Alias("get") - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("dictionary-id", "Dictionary ID").Required().StringVar(&c.Input.DictionaryID) - c.CmdClause.Flag("key", "Dictionary item key").Required().StringVar(&c.Input.ItemKey) - return &c -} - -// Exec invokes the application logic for the command. -func (c *DescribeCommand) Exec(in io.Reader, out io.Writer) error { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - c.Input.ServiceID = serviceID - - dictionary, err := c.Globals.Client.GetDictionaryItem(&c.Input) - if err != nil { - return err - } - - text.Output(out, "Service ID: %s", c.Input.ServiceID) - text.PrintDictionaryItem(out, "", dictionary) - return nil -} diff --git a/pkg/edgedictionaryitem/edgedictionaryitem_test.go b/pkg/edgedictionaryitem/edgedictionaryitem_test.go deleted file mode 100644 index b43058ca8..000000000 --- a/pkg/edgedictionaryitem/edgedictionaryitem_test.go +++ /dev/null @@ -1,455 +0,0 @@ -package edgedictionaryitem_test - -import ( - "bytes" - "errors" - "io" - "net/http" - "os" - "strings" - "testing" - - "github.com/fastly/cli/pkg/app" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/mock" - "github.com/fastly/cli/pkg/testutil" - "github.com/fastly/cli/pkg/update" - "github.com/fastly/go-fastly/v3/fastly" -) - -func TestDictionaryItemDescribe(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"dictionaryitem", "describe", "--service-id", "123", "--key", "foo"}, - api: mock.API{GetDictionaryItemFn: describeDictionaryItemOK}, - wantError: "error parsing arguments: required flag --dictionary-id not provided", - }, - { - args: []string{"dictionaryitem", "describe", "--service-id", "123", "--dictionary-id", "456"}, - api: mock.API{GetDictionaryItemFn: describeDictionaryItemOK}, - wantError: "error parsing arguments: required flag --key not provided", - }, - { - args: []string{"dictionaryitem", "describe", "--service-id", "123", "--dictionary-id", "456", "--key", "foo"}, - api: mock.API{GetDictionaryItemFn: describeDictionaryItemOK}, - wantOutput: describeDictionaryItemOutput, - }, - { - args: []string{"dictionaryitem", "describe", "--service-id", "123", "--dictionary-id", "456", "--key", "foo-deleted"}, - api: mock.API{GetDictionaryItemFn: describeDictionaryItemOKDeleted}, - wantOutput: describeDictionaryItemOutputDeleted, - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertString(t, testcase.wantOutput, out.String()) - }) - } -} - -func TestDictionaryItemsList(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"dictionaryitem", "list", "--service-id", "123"}, - api: mock.API{ListDictionaryItemsFn: listDictionaryItemsOK}, - wantError: "error parsing arguments: required flag --dictionary-id not provided", - }, - { - args: []string{"dictionaryitem", "list", "--service-id", "123", "--dictionary-id", "456"}, - api: mock.API{ListDictionaryItemsFn: listDictionaryItemsOK}, - wantOutput: listDictionaryItemsOutput, - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertString(t, testcase.wantOutput, out.String()) - }) - } -} - -func TestDictionaryItemCreate(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"dictionaryitem", "create", "--service-id", "123"}, - api: mock.API{CreateDictionaryItemFn: createDictionaryItemOK}, - wantError: "error parsing arguments: required flag ", - }, - { - args: []string{"dictionaryitem", "create", "--service-id", "123", "--dictionary-id", "456"}, - api: mock.API{CreateDictionaryItemFn: createDictionaryItemOK}, - wantError: "error parsing arguments: required flag ", - }, - { - args: []string{"dictionaryitem", "create", "--service-id", "123", "--dictionary-id", "456", "--key", "foo", "--value", "bar"}, - api: mock.API{CreateDictionaryItemFn: createDictionaryItemOK}, - wantOutput: "\nSUCCESS: Created dictionary item foo (service 123, dictionary 456)\n", - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertString(t, testcase.wantOutput, out.String()) - }) - } -} - -func TestDictionaryItemUpdate(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"dictionaryitem", "update", "--service-id", "123"}, - api: mock.API{UpdateDictionaryItemFn: updateDictionaryItemOK}, - wantError: "error parsing arguments: required flag ", - }, - { - args: []string{"dictionaryitem", "update", "--service-id", "123", "--dictionary-id", "456"}, - api: mock.API{UpdateDictionaryItemFn: updateDictionaryItemOK}, - wantError: "error parsing arguments: required flag ", - }, - { - args: []string{"dictionaryitem", "update", "--service-id", "123", "--dictionary-id", "456", "--key", "foo", "--value", "bar"}, - api: mock.API{UpdateDictionaryItemFn: updateDictionaryItemOK}, - wantOutput: updateDictionaryItemOutput, - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertString(t, testcase.wantOutput, out.String()) - }) - } -} - -func TestDictionaryItemDelete(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"dictionaryitem", "delete", "--service-id", "123"}, - api: mock.API{DeleteDictionaryItemFn: deleteDictionaryItemOK}, - wantError: "error parsing arguments: required flag ", - }, - { - args: []string{"dictionaryitem", "delete", "--service-id", "123", "--dictionary-id", "456"}, - api: mock.API{DeleteDictionaryItemFn: deleteDictionaryItemOK}, - wantError: "error parsing arguments: required flag ", - }, - { - args: []string{"dictionaryitem", "delete", "--service-id", "123", "--dictionary-id", "456", "--key", "foo"}, - api: mock.API{DeleteDictionaryItemFn: deleteDictionaryItemOK}, - wantOutput: "\nSUCCESS: Deleted dictionary item foo (service 123, dicitonary 456)\n", - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertString(t, testcase.wantOutput, out.String()) - }) - } -} - -func TestDictionaryItemBatchModify(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - fileData string - wantError string - wantOutput string - }{ - { - args: []string{"dictionaryitem", "batchmodify", "--service-id", "123"}, - wantError: "error parsing arguments: required flag ", - }, - { - args: []string{"dictionaryitem", "batchmodify", "--service-id", "123", "--dictionary-id", "456"}, - wantError: "error parsing arguments: required flag --file not provided", - }, - { - fileData: `{invalid": "json"}`, - args: []string{"dictionaryitem", "batchmodify", "--service-id", "123", "--dictionary-id", "456", "--file", "filePath"}, - wantError: "invalid character 'i' looking for beginning of object key string", - }, - { - fileData: `{"valid": "json"}`, - args: []string{"dictionaryitem", "batchmodify", "--service-id", "123", "--dictionary-id", "456", "--file", "filePath"}, - wantError: "item key not found in file ", - }, - { - args: []string{"dictionaryitem", "batchmodify", "--service-id", "123", "--dictionary-id", "456", "--file", "missingFile"}, - wantError: "open missingFile", - }, - { - fileData: dictionaryItemBatchModifyInputOK, - args: []string{"dictionaryitem", "batchmodify", "--service-id", "123", "--dictionary-id", "456", "--file", "filePath"}, - api: mock.API{BatchModifyDictionaryItemsFn: batchModifyDictionaryItemsError}, - wantError: errTest.Error(), - }, - { - fileData: dictionaryItemBatchModifyInputOK, - args: []string{"dictionaryitem", "batchmodify", "--service-id", "123", "--dictionary-id", "456", "--file", "filePath"}, - api: mock.API{BatchModifyDictionaryItemsFn: batchModifyDictionaryItemsOK}, - wantOutput: "\nSUCCESS: Made 4 modifications of Dictionary 456 on service 123\n", - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var filePath string - if testcase.fileData != "" { - filePath = testutil.MakeTempFile(t, testcase.fileData) - defer os.RemoveAll(filePath) - } - - // Insert temp file path into args when "filePath" is present as placeholder - for i, v := range testcase.args { - if v == "filePath" { - testcase.args[i] = filePath - } - } - - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertString(t, testcase.wantOutput, out.String()) - }) - } -} - -func describeDictionaryItemOK(i *fastly.GetDictionaryItemInput) (*fastly.DictionaryItem, error) { - return &fastly.DictionaryItem{ - ServiceID: i.ServiceID, - DictionaryID: i.DictionaryID, - ItemKey: i.ItemKey, - ItemValue: "bar", - CreatedAt: testutil.MustParseTimeRFC3339("2001-02-03T04:05:06Z"), - UpdatedAt: testutil.MustParseTimeRFC3339("2001-02-03T04:05:07Z"), - }, nil -} - -var describeDictionaryItemOutput = `Service ID: 123 -Dictionary ID: 456 -Item Key: foo -Item Value: bar -Created (UTC): 2001-02-03 04:05 -Last edited (UTC): 2001-02-03 04:05 -` - -var updateDictionaryItemOutput = ` -SUCCESS: Updated dictionary item (service 123) - -Dictionary ID: 456 -Item Key: foo -Item Value: bar -Created (UTC): 2001-02-03 04:05 -Last edited (UTC): 2001-02-03 04:05 -` - -func describeDictionaryItemOKDeleted(i *fastly.GetDictionaryItemInput) (*fastly.DictionaryItem, error) { - return &fastly.DictionaryItem{ - ServiceID: i.ServiceID, - DictionaryID: i.DictionaryID, - ItemKey: i.ItemKey, - ItemValue: "bar", - CreatedAt: testutil.MustParseTimeRFC3339("2001-02-03T04:05:06Z"), - UpdatedAt: testutil.MustParseTimeRFC3339("2001-02-03T04:05:07Z"), - DeletedAt: testutil.MustParseTimeRFC3339("2001-02-03T04:06:08Z"), - }, nil -} - -var describeDictionaryItemOutputDeleted = strings.TrimSpace(` -Service ID: 123 -Dictionary ID: 456 -Item Key: foo-deleted -Item Value: bar -Created (UTC): 2001-02-03 04:05 -Last edited (UTC): 2001-02-03 04:05 -Deleted (UTC): 2001-02-03 04:06 -`) + "\n" - -func listDictionaryItemsOK(i *fastly.ListDictionaryItemsInput) ([]*fastly.DictionaryItem, error) { - return []*fastly.DictionaryItem{ - { - ServiceID: i.ServiceID, - DictionaryID: i.DictionaryID, - ItemKey: "foo", - ItemValue: "bar", - CreatedAt: testutil.MustParseTimeRFC3339("2001-02-03T04:05:06Z"), - UpdatedAt: testutil.MustParseTimeRFC3339("2001-02-03T04:05:07Z"), - }, - { - ServiceID: i.ServiceID, - DictionaryID: i.DictionaryID, - ItemKey: "baz", - ItemValue: "bear", - CreatedAt: testutil.MustParseTimeRFC3339("2001-02-03T04:05:06Z"), - UpdatedAt: testutil.MustParseTimeRFC3339("2001-02-03T04:05:07Z"), - DeletedAt: testutil.MustParseTimeRFC3339("2001-02-03T04:06:08Z"), - }, - }, nil -} - -var listDictionaryItemsOutput = strings.TrimSpace(` -Service ID: 123 -Item: 1/2 - Dictionary ID: 456 - Item Key: foo - Item Value: bar - Created (UTC): 2001-02-03 04:05 - Last edited (UTC): 2001-02-03 04:05 - -Item: 2/2 - Dictionary ID: 456 - Item Key: baz - Item Value: bear - Created (UTC): 2001-02-03 04:05 - Last edited (UTC): 2001-02-03 04:05 - Deleted (UTC): 2001-02-03 04:06 -`) + "\n\n" - -func createDictionaryItemOK(i *fastly.CreateDictionaryItemInput) (*fastly.DictionaryItem, error) { - return &fastly.DictionaryItem{ - ServiceID: i.ServiceID, - DictionaryID: i.DictionaryID, - ItemKey: i.ItemKey, - ItemValue: i.ItemValue, - CreatedAt: testutil.MustParseTimeRFC3339("2001-02-03T04:05:06Z"), - UpdatedAt: testutil.MustParseTimeRFC3339("2001-02-03T04:05:07Z"), - }, nil -} - -func updateDictionaryItemOK(i *fastly.UpdateDictionaryItemInput) (*fastly.DictionaryItem, error) { - return &fastly.DictionaryItem{ - ServiceID: i.ServiceID, - DictionaryID: i.DictionaryID, - ItemKey: i.ItemKey, - ItemValue: i.ItemValue, - CreatedAt: testutil.MustParseTimeRFC3339("2001-02-03T04:05:06Z"), - UpdatedAt: testutil.MustParseTimeRFC3339("2001-02-03T04:05:07Z"), - }, nil -} - -func deleteDictionaryItemOK(i *fastly.DeleteDictionaryItemInput) error { - return nil -} - -var dictionaryItemBatchModifyInputOK = ` -{ - "items": [ - { - "op": "create", - "item_key": "some_key", - "item_value": "new_value" - }, - { - "op": "update", - "item_key": "some_key", - "item_value": "new_value" - }, - { - "op": "upsert", - "item_key": "some_key", - "item_value": "new_value" - }, - { - "op": "delete", - "item_key": "some_key" - } - ] -}` - -func batchModifyDictionaryItemsOK(i *fastly.BatchModifyDictionaryItemsInput) error { - return nil -} - -func batchModifyDictionaryItemsError(i *fastly.BatchModifyDictionaryItemsInput) error { - return errTest -} - -var errTest = errors.New("an expected error ocurred") diff --git a/pkg/edgedictionaryitem/list.go b/pkg/edgedictionaryitem/list.go deleted file mode 100644 index 4384c5b11..000000000 --- a/pkg/edgedictionaryitem/list.go +++ /dev/null @@ -1,54 +0,0 @@ -package edgedictionaryitem - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// ListCommand calls the Fastly API to list dictionary items. -type ListCommand struct { - common.Base - manifest manifest.Data - Input fastly.ListDictionaryItemsInput -} - -// NewListCommand returns a usable command registered under the parent. -func NewListCommand(parent common.Registerer, globals *config.Data) *ListCommand { - var c ListCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("list", "List items in a Fastly edge dictionary") - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("dictionary-id", "Dictionary ID").Required().StringVar(&c.Input.DictionaryID) - return &c -} - -// Exec invokes the application logic for the command. -func (c *ListCommand) Exec(in io.Reader, out io.Writer) error { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - c.Input.ServiceID = serviceID - - dictionaries, err := c.Globals.Client.ListDictionaryItems(&c.Input) - if err != nil { - return err - } - - text.Output(out, "Service ID: %s\n", c.Input.ServiceID) - for i, dictionary := range dictionaries { - text.Output(out, "Item: %d/%d", i+1, len(dictionaries)) - text.PrintDictionaryItem(out, "\t", dictionary) - text.Break(out) - } - - return nil -} diff --git a/pkg/edgedictionaryitem/root.go b/pkg/edgedictionaryitem/root.go deleted file mode 100644 index b42492742..000000000 --- a/pkg/edgedictionaryitem/root.go +++ /dev/null @@ -1,28 +0,0 @@ -package edgedictionaryitem - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/config" -) - -// RootCommand is the parent command for all subcommands in this package. -// It should be installed under the primary root command. -type RootCommand struct { - common.Base - // no flags -} - -// NewRootCommand returns a new command registered in the parent. -func NewRootCommand(parent common.Registerer, globals *config.Data) *RootCommand { - var c RootCommand - c.Globals = globals - c.CmdClause = parent.Command("dictionaryitem", "Manipulate Fastly edge dictionary items") - return &c -} - -// Exec implements the command interface. -func (c *RootCommand) Exec(in io.Reader, out io.Writer) error { - panic("unreachable") -} diff --git a/pkg/edgedictionaryitem/update.go b/pkg/edgedictionaryitem/update.go deleted file mode 100644 index 3e573e9f2..000000000 --- a/pkg/edgedictionaryitem/update.go +++ /dev/null @@ -1,56 +0,0 @@ -package edgedictionaryitem - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// UpdateCommand calls the Fastly API to update a dictionary item. -type UpdateCommand struct { - common.Base - manifest manifest.Data - Input fastly.UpdateDictionaryItemInput -} - -// NewUpdateCommand returns a usable command registered under the parent. -// -// TODO(integralist) update to not use common.OptionalString once we have a -// new Go-Fastly release that modifies UpdateDictionaryItemInput so that the -// ItemValue is no longer optional. -func NewUpdateCommand(parent common.Registerer, globals *config.Data) *UpdateCommand { - var c UpdateCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("update", "Update or insert an item on a Fastly edge dictionary") - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("dictionary-id", "Dictionary ID").Required().StringVar(&c.Input.DictionaryID) - c.CmdClause.Flag("key", "Dictionary item key").Required().StringVar(&c.Input.ItemKey) - c.CmdClause.Flag("value", "Dictionary item value").Required().StringVar(&c.Input.ItemValue) - return &c -} - -// Exec invokes the application logic for the command. -func (c *UpdateCommand) Exec(in io.Reader, out io.Writer) error { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - c.Input.ServiceID = serviceID - - d, err := c.Globals.Client.UpdateDictionaryItem(&c.Input) - if err != nil { - return err - } - - text.Success(out, "Updated dictionary item (service %s)", d.ServiceID) - text.Break(out) - text.PrintDictionaryItem(out, "", d) - return nil -} diff --git a/pkg/env/doc.go b/pkg/env/doc.go new file mode 100644 index 000000000..ef7954972 --- /dev/null +++ b/pkg/env/doc.go @@ -0,0 +1,2 @@ +// Package env contains environment variable constants. +package env diff --git a/pkg/env/env.go b/pkg/env/env.go new file mode 100644 index 000000000..dcf3643bc --- /dev/null +++ b/pkg/env/env.go @@ -0,0 +1,87 @@ +package env + +import ( + "fmt" + "os" + "strings" + + "github.com/fastly/cli/pkg/runtime" +) + +const ( + // AccountEndpoint is the env var we look in for the Accounts endpoint. + // e.g. https://accounts.fastly.com + AccountEndpoint = "FASTLY_ACCOUNT_ENDPOINT" + + // APIEndpoint is the env var we look in for the API endpoint. + // e.g. https://api.fastly.com + APIEndpoint = "FASTLY_API_ENDPOINT" + + // APIToken is the env var we look in for the Fastly API token. + // gosec flagged this: + // G101 (CWE-798): Potential hardcoded credentials + // Disabling as we use the value in the command help output. + // #nosec + APIToken = "FASTLY_API_TOKEN" + + // CustomerID is the env var we look in for a Customer ID. + CustomerID = "FASTLY_CUSTOMER_ID" + + // DebugMode indicates to the CLI it can display debug information. + // Set to "true" to enable debug mode. + DebugMode = "FASTLY_DEBUG_MODE" + + // ServiceID is the env var we look in for the required Service ID. + ServiceID = "FASTLY_SERVICE_ID" + + // UseSSO enables the CLI to validate the token as an OAuth token. + // These tokens aren't traditional tokens generated by the UI. + // Instead they generated via an OAuth flow (producing access/refresh tokens). + // Assigned value should be a boolean 1/0 (enable/disable). + UseSSO = "FASTLY_USE_SSO" + + // WasmMetadataDisable is the env var we look in to disable all data + // collection related to a Wasm binary. + // Set to "true" to disable all forms of data collection. + WasmMetadataDisable = "FASTLY_WASM_METADATA_DISABLE" +) + +// Parse transforms the local environment data structure into a map type. +func Parse(environ []string) map[string]string { + env := map[string]string{} + for _, kv := range environ { + k, v, ok := strings.Cut(kv, "=") + if !ok { + continue + } + env[k] = v + } + return env +} + +// Vars returns a slice of environment variables appropriate to platform. +// *nix: $HOME, $USER, ... +// Windows: %HOME%, %USER%, ... +func Vars() []string { + vars := []string{} + for _, e := range os.Environ() { + pair := strings.SplitN(e, "=", 2) + vars = append(vars, toVar(pair[0])) + } + return vars +} + +func toVar(v string) string { + if runtime.Windows { + return toWin(v) + } + return toNix(v) +} + +func toNix(v string) string { + return fmt.Sprintf("\\$%s", v) +} + +func toWin(v string) string { + return fmt.Sprintf("%%%s%%", v) +} diff --git a/pkg/env/env_test.go b/pkg/env/env_test.go new file mode 100644 index 000000000..4b20b6ebc --- /dev/null +++ b/pkg/env/env_test.go @@ -0,0 +1,43 @@ +package env + +import ( + "runtime" + "testing" + + "golang.org/x/exp/slices" +) + +func TestVars(t *testing.T) { + tcs := []struct { + os string + vars map[string]string + expected []string + }{ + { + os: "windows", + expected: []string{"%HOME%", "%PATH%"}, + }, + { + os: "darwin", + expected: []string{"\\$HOME", "\\$PATH"}, + }, + { + os: "linux", + expected: []string{"\\$HOME", "\\$PATH"}, + }, + } + for _, tc := range tcs { + t.Run(tc.os, func(t *testing.T) { + vars := Vars() + if runtime.GOOS == tc.os { + for _, v := range tc.expected { + if !slices.Contains(vars, v) { + t.Errorf("expected %s in %v", v, vars) + } + } + } else { + t.Skip() + } + }) + } +} diff --git a/pkg/errors/deduce.go b/pkg/errors/deduce.go index 4e24154a2..211366855 100644 --- a/pkg/errors/deduce.go +++ b/pkg/errors/deduce.go @@ -7,7 +7,7 @@ import ( "os" "strings" - "github.com/fastly/go-fastly/v3/fastly" + "github.com/fastly/go-fastly/v10/fastly" ) // Deduce attempts to deduce a RemediationError from a plain error. If the error @@ -23,11 +23,12 @@ func Deduce(err error) RemediationError { var httpError *fastly.HTTPError if errors.As(err, &httpError) { - var remediation string - switch httpError.StatusCode { - case http.StatusUnauthorized: + remediation := BugRemediation + + if httpError.StatusCode == http.StatusUnauthorized { remediation = AuthRemediation } + return RemediationError{Inner: SimplifyFastlyError(*httpError), Remediation: remediation} } @@ -56,7 +57,7 @@ func SimplifyFastlyError(httpError fastly.HTTPError) error { if detail := httpError.Errors[0].Detail; detail != "" { s += fmt.Sprintf(" (%s)", detail) } - return fmt.Errorf(s) + return errors.New(s) default: return fmt.Errorf( diff --git a/pkg/errors/deduce_test.go b/pkg/errors/deduce_test.go index 27848c808..3f1064a1f 100644 --- a/pkg/errors/deduce_test.go +++ b/pkg/errors/deduce_test.go @@ -8,7 +8,7 @@ import ( "github.com/fastly/cli/pkg/errors" "github.com/fastly/cli/pkg/testutil" - "github.com/fastly/go-fastly/v3/fastly" + "github.com/fastly/go-fastly/v10/fastly" ) func TestDeduce(t *testing.T) { @@ -38,7 +38,7 @@ func TestDeduce(t *testing.T) { { name: "fastly.HTTPError 503", input: http503, - want: errors.RemediationError{Inner: errors.SimplifyFastlyError(*http503)}, + want: errors.RemediationError{Inner: errors.SimplifyFastlyError(*http503), Remediation: errors.BugRemediation}, }, { name: "fastly.HTTPError 401", diff --git a/pkg/errors/doc.go b/pkg/errors/doc.go new file mode 100644 index 000000000..462ddb103 --- /dev/null +++ b/pkg/errors/doc.go @@ -0,0 +1,2 @@ +// Package errors contains functions to handle Fastly error types. +package errors diff --git a/pkg/errors/errors.go b/pkg/errors/errors.go index 2f560c732..474caea8e 100644 --- a/pkg/errors/errors.go +++ b/pkg/errors/errors.go @@ -1,21 +1,179 @@ package errors -import "fmt" +import ( + "errors" + "fmt" +) + +// ErrSignalInterrupt means a SIGINT was received. +var ErrSignalInterrupt = fmt.Errorf("a SIGINT was received") + +// ErrSignalKilled means a SIGTERM was received. +var ErrSignalKilled = fmt.Errorf("a SIGTERM was received") + +// ErrViceroyRestart means the viceroy binary needs to be restarted due to a +// file modification noticed while running `compute serve --watch`. +var ErrViceroyRestart = fmt.Errorf("a RESTART was initiated") + +// ErrDontContinue means the user said "NO" when prompted whether to continue. +var ErrDontContinue = fmt.Errorf("will not continue") + +// ErrIncompatibleServeFlags means no --skip-build can't be used with --watch +// because it defeats the purpose of --watch which is designed to restart +// Viceroy whenever changes are detected (those changes would not be seen if we +// allowed --skip-build with --watch). +var ErrIncompatibleServeFlags = RemediationError{ + Inner: fmt.Errorf("--skip-build shouldn't be used with --watch"), + Remediation: ComputeServeRemediation, +} // ErrNoToken means no --token has been provided. -var ErrNoToken = RemediationError{Inner: fmt.Errorf("no token provided"), Remediation: AuthRemediation} +var ErrNoToken = RemediationError{ + Inner: fmt.Errorf("no token provided"), + Remediation: AuthRemediation, +} -// ErrNoServiceID means no --service-id or service_id package manifest value has +// ErrNoServiceID means no --service-id or service_id fastly.toml value has // been provided. -var ErrNoServiceID = RemediationError{Inner: fmt.Errorf("error reading service: no service ID found"), Remediation: ServiceIDRemediation} +var ErrNoServiceID = RemediationError{ + Inner: fmt.Errorf("error reading service: no service ID found"), + Remediation: ServiceIDRemediation, +} + +// ErrNoCustomerID means no --customer-id or FASTLY_CUSTOMER_ID environment +// variable found. +var ErrNoCustomerID = RemediationError{ + Inner: fmt.Errorf("error reading customer ID: no customer ID found"), + Remediation: CustomerIDRemediation, +} // ErrMissingManifestVersion means an invalid manifest (fastly.toml) has been used. -var ErrMissingManifestVersion = RemediationError{Inner: fmt.Errorf("no manifest_version found in the fastly.toml"), Remediation: BugRemediation} +var ErrMissingManifestVersion = RemediationError{ + Inner: fmt.Errorf("no manifest_version found in the fastly.toml"), + Remediation: BugRemediation, +} // ErrUnrecognisedManifestVersion means an invalid manifest (fastly.toml) // version has been specified. -var ErrUnrecognisedManifestVersion = RemediationError{Inner: fmt.Errorf("unrecognised manifest_version found in the fastly.toml"), Remediation: BugRemediation} +var ErrUnrecognisedManifestVersion = RemediationError{ + Inner: fmt.Errorf("unrecognised manifest_version found in the fastly.toml"), + Remediation: UnrecognisedManifestVersionRemediation, +} + +// ErrIncompatibleManifestVersion means the manifest_version defined is no +// longer compatible with the current CLI version. +var ErrIncompatibleManifestVersion = RemediationError{ + Inner: fmt.Errorf("the fastly.toml contains an incompatible manifest_version number"), + Remediation: "Update the `manifest_version` in the fastly.toml and refer to https://www.fastly.com/documentation/reference/compute/fastly-toml for changes to the manifest structure", +} + +// ErrNoID means no --id value has been provided. +var ErrNoID = RemediationError{ + Inner: fmt.Errorf("no ID found"), + Remediation: IDRemediation, +} + +// ErrReadingManifest means there was a problem reading the fastly.toml. +var ErrReadingManifest = RemediationError{ + Inner: fmt.Errorf("error reading fastly.toml"), + Remediation: "Ensure the Fastly CLI is being run within a directory containing a fastly.toml file. " + ComputeInitRemediation, +} + +// ErrParsingManifest means there was a problem unmarshalling the fastly.toml. +var ErrParsingManifest = RemediationError{ + Inner: fmt.Errorf("error parsing fastly.toml"), + Remediation: ComputeInitRemediation, +} + +// ErrStopWalk is used to indicate to filepath.WalkDir that it should stop +// walking the directory tree. +var ErrStopWalk = errors.New("stop directory walking") + +// ErrInvalidArchive means the package archive didn't contain a recognised +// directory structure. +var ErrInvalidArchive = RemediationError{ + Inner: fmt.Errorf("invalid package archive structure"), + Remediation: "Ensure the archive contains all required package files (such as a 'fastly.toml' manifest, and a 'src' folder etc).", +} + +// ErrPostInitStopped means the user stopped the init process because they were +// unhappy with the custom post_init defined in the fastly.toml manifest file. +var ErrPostInitStopped = RemediationError{ + Inner: fmt.Errorf("init process stopped by user"), + Remediation: "Check the [scripts.post_init] in the fastly.toml manifest is safe to execute or skip this prompt using either `--auto-yes` or `--non-interactive`.", +} + +// ErrPostBuildStopped means the user stopped the build because they were unhappy +// with the custom build defined in the fastly.toml manifest file. +var ErrPostBuildStopped = RemediationError{ + Inner: fmt.Errorf("build process stopped by user"), + Remediation: "Check the [scripts.post_build] in the fastly.toml manifest is safe to execute or skip this prompt using either `--auto-yes` or `--non-interactive`.", +} + +// ErrInvalidVerboseJSONCombo means the user provided both a --verbose and +// --json flag which are mutually exclusive behaviours. +var ErrInvalidVerboseJSONCombo = RemediationError{ + Inner: fmt.Errorf("invalid flag combination, --verbose and --json"), + Remediation: "Use either --verbose or --json, not both.", +} + +// ErrInvalidDeleteAllJSONKeyCombo means the user provided both a --all and +// --json flag which are mutually exclusive behaviours. +var ErrInvalidDeleteAllJSONKeyCombo = RemediationError{ + Inner: fmt.Errorf("invalid flag combination, --all and --json"), + Remediation: "Use either --all or --json, not both.", +} + +// ErrInvalidDeleteAllKeyCombo means the user provided both a --all and --key +// flag which are mutually exclusive behaviours. +var ErrInvalidDeleteAllKeyCombo = RemediationError{ + Inner: fmt.Errorf("invalid flag combination, --all and --key"), + Remediation: "Use either --all or --key, not both.", +} + +// ErrMissingDeleteAllKeyCombo means the user omitted both the --all and --key +// flags and we need at least one of them. +var ErrMissingDeleteAllKeyCombo = RemediationError{ + Inner: fmt.Errorf("invalid command, neither --all or --key provided"), + Remediation: "Provide at least one of: --all or --key, not both.", +} + +// ErrNoSTDINData indicates the --stdin flag was specified but no data was piped +// into stdin. +var ErrNoSTDINData = RemediationError{ + Inner: fmt.Errorf("unable to read from STDIN"), + Remediation: "Provide data to STDIN, or use --file to read from a file", +} + +// ErrInvalidKVCombo means the user omitted either the key or value flag. +var ErrInvalidKVCombo = RemediationError{ + Inner: fmt.Errorf("--key and --value are required"), + Remediation: "Please add both flags or alternatively use either --stdin or --file.", +} + +// ErrInvalidStdinFileDirCombo means the user provided more than one of --stdin, +// --file or --dir flags, which are mutally exclusive behaviours. +var ErrInvalidStdinFileDirCombo = RemediationError{ + Inner: fmt.Errorf("invalid flag combination"), + Remediation: "Use only one of --stdin, --file or --dir.", +} + +// ErrInvalidProfileSSOCombo means the user specified both --sso and +// --automation-token and only one should be set. +var ErrInvalidProfileSSOCombo = RemediationError{ + Inner: fmt.Errorf("invalid command, both --sso and --automation-token provided"), + Remediation: "Provide at only one of: --sso or --automation-token, not both.", +} + +// ErrInvalidEnableDisableFlagCombo means the user provided both a --enable +// and --disable flag which are mutually exclusive behaviours. +var ErrInvalidEnableDisableFlagCombo = RemediationError{ + Inner: fmt.Errorf("invalid flag combination: --enable and --disable"), + Remediation: "Use either --enable or --disable, not both.", +} -// ErrInvalidManifestVersion means the manifest_version is defined as a toml -// section. -var ErrInvalidManifestVersion = RemediationError{Inner: fmt.Errorf("failed to parse fastly.toml when checking if manifest_version was valid"), Remediation: "Delete `[manifest_version]` from the fastly.toml if present"} +// ErrInvalidComputeACLCombo means the user omitted either the operation, prefix, or action flag. +var ErrInvalidComputeACLCombo = RemediationError{ + Inner: fmt.Errorf("--operation, --prefix, and --action are required"), + Remediation: "Please add all three flags or or alternatively use --file.", +} diff --git a/pkg/errors/exit_error.go b/pkg/errors/exit_error.go new file mode 100644 index 000000000..e9c8ddcb6 --- /dev/null +++ b/pkg/errors/exit_error.go @@ -0,0 +1,36 @@ +package errors + +import ( + "io" + + "github.com/fastly/cli/pkg/text" +) + +// SkipExitError is an error that can cause the os.Exit(1) to be skipped. +// An example is 'help' output (e.g. --help). +type SkipExitError struct { + Skip bool + Err error +} + +// Unwrap returns the inner error. +func (ee SkipExitError) Unwrap() error { + return ee.Err +} + +// Error prints the inner error string. +func (ee SkipExitError) Error() string { + if ee.Err == nil { + return "" + } + return ee.Err.Error() +} + +// Print the error to the io.Writer for human consumption. +// The inner error is always printed via text.Output with an "Error: " prefix +// and a "." suffix. +func (ee SkipExitError) Print(w io.Writer) { + if ee.Err != nil { + text.Error(w, "%s.", ee.Err.Error()) + } +} diff --git a/pkg/errors/log.go b/pkg/errors/log.go new file mode 100644 index 000000000..e40d98c1c --- /dev/null +++ b/pkg/errors/log.go @@ -0,0 +1,233 @@ +package errors + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "runtime" + "strings" + "sync" + "text/template" + "time" + + "github.com/fastly/go-fastly/v10/fastly" +) + +// LogPath is the location of the fastly CLI error log. +var LogPath = func() string { + if dir, err := os.UserConfigDir(); err == nil { + return filepath.Join(dir, "fastly", "errors.log") + } + if dir, err := os.UserHomeDir(); err == nil { + return filepath.Join(dir, ".fastly", "errors.log") + } + panic("unable to deduce user config dir or user home dir") +}() + +// LogInterface represents the LogEntries behaviours. +type LogInterface interface { + Add(err error) + AddWithContext(err error, ctx map[string]any) + Persist(logPath string, args []string) error +} + +// MockLog is a no-op Log type. +type MockLog struct{} + +// Add adds an error to the mock log. +func (ml MockLog) Add(_ error) {} + +// AddWithContext adds an error and context to the mock log. +func (ml MockLog) AddWithContext(_ error, _ map[string]any) {} + +// Persist writes the error data to logPath. +func (ml MockLog) Persist(_ string, _ []string) error { + return nil +} + +// Log is the primary interface for consumers. +var Log = new(LogEntries) + +// LogEntries represents a list of recorded log entries. +type LogEntries []LogEntry + +// Add adds a new log entry. +func (l *LogEntries) Add(err error) { + logMutex.Lock() + *l = append(*l, createLogEntry(err)) + logMutex.Unlock() +} + +// AddWithContext adds a new log entry with extra contextual data. +func (l *LogEntries) AddWithContext(err error, ctx map[string]any) { + le := createLogEntry(err) + le.Context = ctx + + logMutex.Lock() + *l = append(*l, le) + logMutex.Unlock() +} + +// Persist persists recorded log entries to disk. +func (l LogEntries) Persist(logPath string, args []string) error { + if len(l) == 0 { + return nil + } + cmd := "fastly " + strings.Join(args, " ") + errMsg := "error accessing audit log file: %w" + + // gosec flagged this: + // G304 (CWE-22): Potential file inclusion via variable + // + // Disabling as the input is determined from our own package. + /* #nosec */ + f, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) + if err != nil { + return fmt.Errorf(errMsg, err) + } + + if fi, err := f.Stat(); err == nil { + if fi.Size() >= FileRotationSize { + err = f.Close() + if err != nil { + return err + } + + // gosec flagged this: + // G304 (CWE-22): Potential file inclusion via variable + // + // Disabling as the input is determined from our own package. + /* #nosec */ + f, err = os.Create(logPath) + if err != nil { + return fmt.Errorf(errMsg, err) + } + } + } + + // G307 (CWE-): Deferring unsafe method "*os.File" on type "Close". + // gosec flagged this: + // Disabling because this file isn't critical to the functioning of the CLI + // and we only attempt to close it at the end of the user's execution flow. + /* #nosec */ + defer f.Close() + + cmd = "\nCOMMAND:\n" + cmd + "\n\n" + if _, err := f.Write([]byte(cmd)); err != nil { + return err + } + + record := `TIMESTAMP: +{{.Time}} + +ERROR: +{{.Err}} +{{ range $key, $value := .Caller }} +{{ $key }}: +{{ $value }} +{{ end }} +{{ range $key, $value := .Context }} + {{ $key }}: {{ $value }} +{{ end }} +` + t := template.Must(template.New("record").Parse(record)) + for _, entry := range l { + err := t.Execute(f, entry) + if err != nil { + return err + } + } + + if _, err := f.Write([]byte("------------------------------\n\n")); err != nil { + return err + } + + return nil +} + +var ( + // TokenRegEx matches a Token as part of the error output (https://regex101.com/r/ulIw1m/1) + TokenRegEx = regexp.MustCompile(`Token ([\w-]+)`) + // TokenFlagRegEx matches the token flag (https://regex101.com/r/YNr78Q/1) + TokenFlagRegEx = regexp.MustCompile(`(-t|--token)(\s*=?\s*['"]?)([\w-]+)(['"]?)`) +) + +// FilterToken replaces any matched patterns with "REDACTED". +// +// EXAMPLE: https://go.dev/play/p/cT4BwIh9Asa +func FilterToken(input string) (inputFiltered string) { + inputFiltered = TokenRegEx.ReplaceAllString(input, "Token REDACTED") + inputFiltered = TokenFlagRegEx.ReplaceAllString(inputFiltered, "${1}${2}REDACTED${4}") + return inputFiltered +} + +// createLogEntry generates the boilerplate of a LogEntry. +func createLogEntry(err error) LogEntry { + le := LogEntry{ + Time: Now(), + Err: err, + } + + _, file, line, ok := runtime.Caller(2) + if ok { + idx := strings.Index(file, "/pkg/") + if idx == -1 { + idx = 0 + } + le.Caller = map[string]any{ + "FILE": file[idx:], + "LINE": line, + } + } + + return le +} + +// LogEntry represents a single error log entry. +type LogEntry struct { + Time time.Time + Err error + Caller map[string]any + Context map[string]any +} + +// Caller represents where an error occurred. +type Caller struct { + File string + Line int +} + +// Appending to a slice isn't threadsafe, and although we currently don't +// expect this to be a problem we can't predict future logic requirements that +// might result in more asynchronous operations, so we play it safe and utilise +// a lock before updating the LogEntries. +var logMutex sync.Mutex + +// Now is exposed so that we may mock it from our test file. +// +// NOTE: The ideal way to deal with time is to inject it as a dependency and +// then the caller can provide a stubbed value, but in this case we don't want +// to have the CLI's business logic littered with lots of calls to time.Now() +// when that call can be handled internally by the .Add() method. +var Now = time.Now + +// FileRotationSize represents the size the log file needs to be before we +// truncate it. +// +// NOTE: To enable easier testing of the log rotation logic, we don't define +// this as a constant but as a variable so the test file can mutate the value +// to something much smaller, meaning we can commit a small test file as part +// of the testing logic that will trigger a 'over the threshold' scenario. +var FileRotationSize int64 = 5242880 // 5mb + +// ServiceVersion returns an integer regardless of whether the given argument +// is a nil pointer or not. It helps to reduce the boilerplate found across the +// codebase when tracking errors related to `argparser.ServiceDetails`. +func ServiceVersion(v *fastly.Version) int { + var sv int + if v != nil { + sv = fastly.ToValue(v.Number) + } + return sv +} diff --git a/pkg/errors/log_test.go b/pkg/errors/log_test.go new file mode 100644 index 000000000..fed92e3ff --- /dev/null +++ b/pkg/errors/log_test.go @@ -0,0 +1,216 @@ +package errors_test + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/testutil" +) + +func TestLogAdd(t *testing.T) { + le := new(errors.LogEntries) + le.Add(fmt.Errorf("foo")) + le.Add(fmt.Errorf("bar")) + le.Add(fmt.Errorf("baz")) + + m := make(map[string]any) + m["beep"] = "boop" + m["this"] = "that" + m["nums"] = 123 + le.AddWithContext(fmt.Errorf("qux"), m) + + want := 4 + got := len(*le) + if got != want { + t.Fatalf("want length %d, got: %d", want, got) + } +} + +func TestLogPersist(t *testing.T) { + var path string + + // Create temp environment to run test code within. + { + wd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + + rootdir := testutil.NewEnv(testutil.EnvOpts{ + T: t, + Write: []testutil.FileIO{ + {Src: string(""), Dst: "errors.log"}, + }, + Copy: []testutil.FileIO{ + { + Src: filepath.Join("testdata", "errors-expected.log"), + Dst: "errors-expected.log", + }, + }, + }) + path = filepath.Join(rootdir, "errors.log") + defer os.RemoveAll(rootdir) + + if err := os.Chdir(rootdir); err != nil { + t.Fatal(err) + } + defer func() { + _ = os.Chdir(wd) + }() + } + + errors.Now = func() (t time.Time) { + return t + } + + le := new(errors.LogEntries) + le.Add(fmt.Errorf("foo")) + le.Add(fmt.Errorf("bar")) + le.Add(fmt.Errorf("baz")) + + m := make(map[string]any) + m["beep"] = "boop" + m["this"] = "that" + m["nums"] = 123 + le.AddWithContext(fmt.Errorf("qux"), m) + + err := le.Persist(path, []string{"command", "one", "--example"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + err = le.Persist(path, []string{"command", "two", "--example"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + have, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + + wantPath, err := filepath.Abs("errors-expected.log") + if err != nil { + t.Fatal(err) + } + want, err := os.ReadFile(wantPath) + if err != nil { + t.Fatal(err) + } + + r := strings.NewReplacer("\n", "", "\r", "") + wanttrim := r.Replace(string(want)) + havetrim := r.Replace(string(have)) + + testutil.AssertEqual(t, wanttrim, havetrim) +} + +// TestLogPersistLogRotation validates that if an audit log file exceeds the +// specified threshold, then the file will be deleted and recreated. +// +// The way this is achieved is by creating an errors.log file that has a +// specific size, and then overriding the package level variable that +// determines the threshold so that it matches the size of the file we created. +// This means we can be sure our logic will trigger the file to be replaced +// with a new empty file, to which we'll then write our log content into. +func TestLogPersistLogRotation(t *testing.T) { + var ( + fi os.FileInfo + path string + ) + + // Create temp environment to run test code within. + { + wd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + + // We want to start off with an existing audit log file that we expect to + // be rotated because it exceeded our defined threshold. + seedPath, err := filepath.Abs(filepath.Join("testdata", "errors-expected.log")) + if err != nil { + t.Fatal(err) + } + seed, err := os.ReadFile(seedPath) + if err != nil { + t.Fatal(err) + } + f, err := os.Open(seedPath) + if err != nil { + t.Fatal(err) + } + defer f.Close() + fi, err = f.Stat() + if err != nil { + t.Fatal(err) + } + + rootdir := testutil.NewEnv(testutil.EnvOpts{ + T: t, + Write: []testutil.FileIO{ + {Src: string(seed), Dst: "errors.log"}, + }, + Copy: []testutil.FileIO{ + { + Src: filepath.Join("testdata", "errors-expected-rotation.log"), + Dst: "errors-expected-rotation.log", + }, + }, + }) + path = filepath.Join(rootdir, "errors.log") + defer os.RemoveAll(rootdir) + + if err := os.Chdir(rootdir); err != nil { + t.Fatal(err) + } + defer func() { + _ = os.Chdir(wd) + }() + } + + errors.Now = func() (t time.Time) { + return t + } + errors.FileRotationSize = fi.Size() + + le := new(errors.LogEntries) + le.Add(fmt.Errorf("foo")) + le.Add(fmt.Errorf("bar")) + le.Add(fmt.Errorf("baz")) + + m := make(map[string]any) + m["beep"] = "boop" + m["this"] = "that" + m["nums"] = 123 + le.AddWithContext(fmt.Errorf("qux"), m) + + err := le.Persist(path, []string{"command", "one", "--example"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + have, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + + wantPath, err := filepath.Abs("errors-expected-rotation.log") + if err != nil { + t.Fatal(err) + } + want, err := os.ReadFile(wantPath) + if err != nil { + t.Fatal(err) + } + + r := strings.NewReplacer("\n", "", "\r", "") + wanttrim := r.Replace(string(want)) + havetrim := r.Replace(string(have)) + + testutil.AssertEqual(t, wanttrim, havetrim) +} diff --git a/pkg/errors/process.go b/pkg/errors/process.go new file mode 100644 index 000000000..845f18644 --- /dev/null +++ b/pkg/errors/process.go @@ -0,0 +1,35 @@ +package errors + +import ( + "errors" + "io" + + "github.com/fatih/color" + + "github.com/fastly/cli/pkg/text" +) + +// Process persists the error log to disk and deduces the error type. +func Process(err error, args []string, out io.Writer) (skipExit bool) { + text.Break(out) + + // NOTE: We persist any error log entries to disk before attempting to handle + // a possible error response from app.Run as there could be errors recorded + // during the execution flow but were otherwise handled without bubbling an + // error back the call stack, and so if the user still experiences something + // unexpected we will have a record of any errors that happened along the way. + logErr := Log.Persist(LogPath, args[1:]) + if logErr != nil { + Deduce(logErr).Print(color.Error) + } + + // IMPORTANT: Deduce/Print needs to happen before checking for Skip. + // This is so the help output can be printed. + Deduce(err).Print(color.Error) + + exitError := SkipExitError{} + if errors.As(err, &exitError) { + return exitError.Skip + } + return false +} diff --git a/pkg/errors/remediation_error.go b/pkg/errors/remediation_error.go index 6644bade8..7e191ee29 100644 --- a/pkg/errors/remediation_error.go +++ b/pkg/errors/remediation_error.go @@ -5,14 +5,17 @@ import ( "io" "strings" - "github.com/fastly/cli/pkg/config" + "github.com/fastly/cli/pkg/env" "github.com/fastly/cli/pkg/text" ) // RemediationError wraps a normal error with a suggested remediation. type RemediationError struct { - Prefix string - Inner error + // Prefix is a custom message displayed without modification. + Prefix string + // Inner is the root error. + Inner error + // Remediation provides more context and helpful references. Remediation string } @@ -38,28 +41,28 @@ func (re RemediationError) Print(w io.Writer) { fmt.Fprintf(w, "%s\n\n", strings.TrimRight(re.Prefix, "\r\n")) } if re.Inner != nil { - text.Error(w, "%s.", re.Inner.Error()) // single "\n" ensured by text.Error - } - if re.Inner != nil && re.Remediation != "" { - fmt.Fprintln(w) // additional "\n" to allow breathing room + text.Error(w, "%s.\n\n", re.Inner.Error()) // single "\n" ensured by text.Error } if re.Remediation != "" { fmt.Fprintf(w, "%s\n", strings.TrimRight(re.Remediation, "\r\n")) } } +// FormatTemplate represents a generic error message prefix. +var FormatTemplate = "To fix this error, run the following command:\n\n\t$ %s" + // AuthRemediation suggests checking the provided --token. var AuthRemediation = fmt.Sprintf(strings.Join([]string{ "This error may be caused by a missing, incorrect, or expired Fastly API token.", "Check that you're supplying a valid token, either via --token,", - "through the environment variable %s, or through the config file via `fastly configure`.", + "through the environment variable %s, or through the config file via `fastly profile`.", "Verify that the token is still valid via `fastly whoami`.", -}, " "), config.EnvVarToken) +}, " "), env.APIToken) // NetworkRemediation suggests, somewhat unhelpfully, to try again later. var NetworkRemediation = strings.Join([]string{ "This error may be caused by transient network issues.", - "Please verify your network connection and try again.", + "Please verify your network connection and DNS configuration, and try again.", }, " ") // HostRemediation suggests there might be an issue with the local host. @@ -75,10 +78,23 @@ var BugRemediation = strings.Join([]string{ "https://github.com/fastly/cli/issues/new?labels=bug&template=bug_report.md", }, " ") +// ConfigRemediation informs the user that an error with loading the config +// isn't a breaking error and the CLI can still be used. +var ConfigRemediation = strings.Join([]string{ + "There is a fallback version of the configuration provided with the CLI install", + "(run `fastly config` to view the config) which enables the CLI to continue to be usable even though the config couldn't be updated.", +}, " ") + // ServiceIDRemediation suggests provide a service ID via --service-id flag or -// package manifest. +// fastly.toml. var ServiceIDRemediation = strings.Join([]string{ - "Please provide one via the --service-id flag or within your package manifest", + "Please provide one via the --service-id or --service-name flag, or by setting the FASTLY_SERVICE_ID environment variable, or within your fastly.toml", +}, " ") + +// CustomerIDRemediation suggests provide a customer ID via --customer-id flag +// or via environment variable. +var CustomerIDRemediation = strings.Join([]string{ + "Please provide one via the --customer-id flag, or by setting the FASTLY_CUSTOMER_ID environment variable", }, " ") // ExistingDirRemediation suggests moving to another directory and retrying. @@ -86,3 +102,71 @@ var ExistingDirRemediation = strings.Join([]string{ "Please create a new directory and initialize a new project using:", "`fastly compute init`.", }, " ") + +// AutoCloneRemediation suggests provide an --autoclone flag. +var AutoCloneRemediation = strings.Join([]string{ + "Repeat the command with the --autoclone flag to allow the version to be cloned", +}, " ") + +// IDRemediation suggests an ID via --id flag should be provided. +var IDRemediation = strings.Join([]string{ + "Please provide one via the --id flag", +}, " ") + +// PackageSizeRemediation suggests checking the resources documentation for the +// current package size limit. +var PackageSizeRemediation = strings.Join([]string{ + "Please check our Compute resource limits:", + "https://www.fastly.com/documentation/guides/compute#limitations-and-constraints", +}, " ") + +// UnrecognisedManifestVersionRemediation suggests steps to resolve an issue +// where the project contains a manifest_version that is larger than what the +// current CLI version supports. +var UnrecognisedManifestVersionRemediation = strings.Join([]string{ + "Please try updating the installed CLI version using: `fastly update`.", + "See also https://www.fastly.com/documentation/reference/compute/fastly-toml to check your fastly.toml manifest is up-to-date with the latest data model.", + BugRemediation, +}, " ") + +// ComputeInitRemediation suggests re-running `compute init` to resolve +// manifest issue. +var ComputeInitRemediation = strings.Join([]string{ + "Run `fastly compute init` to ensure a correctly configured manifest.", + "See more at https://www.fastly.com/documentation/reference/compute/fastly-toml", +}, " ") + +// ComputeServeRemediation suggests re-running `compute serve` with one of the +// incompatible flags removed. +var ComputeServeRemediation = strings.Join([]string{ + "The --watch flag enables hot reloading of your project to support a faster feedback loop during local development, and subsequently conflicts with the --skip-build flag which avoids rebuilding your project altogether.", + "Remove one of the flags based on the outcome you require.", +}, " ") + +// ComputeBuildRemediation suggests configuring a `[scripts.build]` setting in +// the fastly.toml manifest. +var ComputeBuildRemediation = strings.Join([]string{ + "Add a [scripts] section with `build = \"%s\"`.", + "See more at https://www.fastly.com/documentation/reference/compute/fastly-toml", +}, " ") + +// ComputeTrialRemediation suggests contacting customer manager to enable the +// free trial feature flag. +var ComputeTrialRemediation = "For more help with this error see fastly.help/cli/ecp-feature" + +// ProfileRemediation suggests no profiles exist. +var ProfileRemediation = "Run `fastly profile create ` to create a profile, or `fastly profile list` to view available profiles (at least one profile should be set as 'default')." + +// InvalidStaticConfigRemediation indicates an unexpected error occurred when +// deserialising the CLI's internal configuration. +var InvalidStaticConfigRemediation = strings.Join([]string{ + "The Fastly CLI attempted to parse an internal configuration file but failed.", + "Run `fastly update` to upgrade your current CLI version.", + "If this does not resolve the issue, then please file an issue:", + "https://github.com/fastly/cli/issues/new?labels=bug&template=bug_report.md", +}, " ") + +// TokenExpirationRemediation indicates that a stored OIDC token has expired. +var TokenExpirationRemediation = strings.Join([]string{ + "Run 'fastly --profile sso' to refresh the token.", +}, " ") diff --git a/pkg/errors/testdata/errors-expected-rotation.log b/pkg/errors/testdata/errors-expected-rotation.log new file mode 100644 index 000000000..b323cb8a4 --- /dev/null +++ b/pkg/errors/testdata/errors-expected-rotation.log @@ -0,0 +1,64 @@ + +COMMAND: +fastly command one --example + +TIMESTAMP: +0001-01-01 00:00:00 +0000 UTC + +ERROR: +foo + +FILE: +/pkg/errors/log_test.go + +LINE: +182 + + +TIMESTAMP: +0001-01-01 00:00:00 +0000 UTC + +ERROR: +bar + +FILE: +/pkg/errors/log_test.go + +LINE: +183 + + +TIMESTAMP: +0001-01-01 00:00:00 +0000 UTC + +ERROR: +baz + +FILE: +/pkg/errors/log_test.go + +LINE: +184 + + +TIMESTAMP: +0001-01-01 00:00:00 +0000 UTC + +ERROR: +qux + +FILE: +/pkg/errors/log_test.go + +LINE: +190 + + + beep: boop + + nums: 123 + + this: that + + +------------------------------ diff --git a/pkg/errors/testdata/errors-expected.log b/pkg/errors/testdata/errors-expected.log new file mode 100644 index 000000000..8a48bfb7a --- /dev/null +++ b/pkg/errors/testdata/errors-expected.log @@ -0,0 +1,127 @@ + +COMMAND: +fastly command one --example + +TIMESTAMP: +0001-01-01 00:00:00 +0000 UTC + +ERROR: +foo + +FILE: +/pkg/errors/log_test.go + +LINE: +72 + + +TIMESTAMP: +0001-01-01 00:00:00 +0000 UTC + +ERROR: +bar + +FILE: +/pkg/errors/log_test.go + +LINE: +73 + + +TIMESTAMP: +0001-01-01 00:00:00 +0000 UTC + +ERROR: +baz + +FILE: +/pkg/errors/log_test.go + +LINE: +74 + + +TIMESTAMP: +0001-01-01 00:00:00 +0000 UTC + +ERROR: +qux + +FILE: +/pkg/errors/log_test.go + +LINE: +80 + + beep: boop + + nums: 123 + + this: that + + +------------------------------ + +COMMAND: +fastly command two --example + +TIMESTAMP: +0001-01-01 00:00:00 +0000 UTC + +ERROR: +foo + +FILE: +/pkg/errors/log_test.go + +LINE: +72 + + +TIMESTAMP: +0001-01-01 00:00:00 +0000 UTC + +ERROR: +bar + +FILE: +/pkg/errors/log_test.go + +LINE: +73 + + +TIMESTAMP: +0001-01-01 00:00:00 +0000 UTC + +ERROR: +baz + +FILE: +/pkg/errors/log_test.go + +LINE: +74 + + +TIMESTAMP: +0001-01-01 00:00:00 +0000 UTC + +ERROR: +qux + +FILE: +/pkg/errors/log_test.go + +LINE: +80 + + + beep: boop + + nums: 123 + + this: that + + +------------------------------ diff --git a/pkg/exec/doc.go b/pkg/exec/doc.go new file mode 100644 index 000000000..bb3eef26b --- /dev/null +++ b/pkg/exec/doc.go @@ -0,0 +1,2 @@ +// Package exec contains helper abstractions for working with external commands. +package exec diff --git a/pkg/exec/exec.go b/pkg/exec/exec.go new file mode 100644 index 000000000..924630be0 --- /dev/null +++ b/pkg/exec/exec.go @@ -0,0 +1,217 @@ +package exec + +import ( + "context" + "fmt" + "io" + "os" + "os/exec" + "os/signal" + "syscall" + "time" + + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/text" + "github.com/fastly/cli/pkg/threadsafe" +) + +// divider is used as separator lines around shell output. +const divider = "--------------------------------------------------------------------------------" + +// Streaming models a generic command execution that consumers can use to +// execute commands and stream their output to an io.Writer. For example +// compute commands can use this to standardize the flow control for each +// compiler toolchain. +type Streaming struct { + // Args are the command positional arguments. + Args []string + // Command is the command to be executed. + Command string + // Env is the environment variables to set. + Env []string + // ForceOutput ensures output is displayed (default: only display on error). + ForceOutput bool + // Output is where to write output (e.g. stdout) + Output io.Writer + // Process is the process to terminal if signal received. + Process *os.Process + // SignalCh is a channel handling signal events. + SignalCh chan os.Signal + // Spinner is a specific spinner instance. + Spinner text.Spinner + // SpinnerMessage is the messaging to use. + SpinnerMessage string + // Timeout is the command timeout. + Timeout time.Duration + // Verbose outputs additional information. + Verbose bool +} + +// MonitorSignals spawns a goroutine that configures signal handling so that +// the long running subprocess can be killed using SIGINT/SIGTERM. +func (s *Streaming) MonitorSignals() { + go s.MonitorSignalsAsync() +} + +// MonitorSignalsAsync configures the signal notifications. +func (s *Streaming) MonitorSignalsAsync() { + signals := []os.Signal{ + syscall.SIGINT, + syscall.SIGTERM, + } + + signal.Notify(s.SignalCh, signals...) + + <-s.SignalCh + signal.Stop(s.SignalCh) + + // NOTE: We don't do error handling here because the user might be doing local + // development with the --watch flag and that workflow will have already + // killed the process. The reason this line still exists is for users running + // their application locally without the --watch flag and who then execute + // Ctrl-C to kill the process. + _ = s.Signal(os.Kill) +} + +// Exec executes the compiler command and pipes the child process stdout and +// stderr output to the supplied io.Writer, it waits for the command to exit +// cleanly or returns an error. +func (s *Streaming) Exec() error { + // Construct the command with given arguments and environment. + var cmd *exec.Cmd + if s.Timeout > 0 { + ctx, cancel := context.WithTimeout(context.Background(), s.Timeout) + defer cancel() + // gosec flagged this: + // G204 (CWE-78): Subprocess launched with variable + // Disabling as the variables come from trusted sources. + // #nosec + // nosemgrep + cmd = exec.CommandContext(ctx, s.Command, s.Args...) + } else { + // gosec flagged this: + // G204 (CWE-78): Subprocess launched with variable + // Disabling as the variables come from trusted sources. + // #nosec + // nosemgrep + cmd = exec.Command(s.Command, s.Args...) + } + cmd.Env = append(os.Environ(), s.Env...) + + // We store all output in a buffer to hide it unless there was an error. + var buf threadsafe.Buffer + var output io.Writer + output = &buf + + // We only display the stored output if there is an error. + // But some commands like `compute serve` expect the full output regardless. + // So for those scenarios they can force all output. + if s.ForceOutput { + output = s.Output + } + + if !s.Verbose { + text.Break(output) + } + text.Info(output, "Command output:") + text.Output(output, divider) + + cmd.Stdout = output + cmd.Stderr = output + + if err := cmd.Start(); err != nil { + text.Output(output, divider) + return err + } + + // Store off os.Process so it can be killed by signal listener. + // + // NOTE: argparser.Process is nil until exec.Start() returns successfully. + s.Process = cmd.Process + + if err := cmd.Wait(); err != nil { + // IMPORTANT: We MUST wrap the original error. + // This is because the `compute serve` command requires it for --watch + // Specifically we need to check the error message for "killed". + // This enables the watching logic to restart the Viceroy binary. + err = fmt.Errorf("error during execution process (see 'command output' above): %w", err) + + text.Output(output, divider) + + // If we're in verbose mode, the build output is shown. + // So in that case we don't want to have a spinner as it'll interweave output. + // In non-verbose mode we have a spinner running while the build is happening. + if !s.Verbose && s.Spinner != nil { + s.Spinner.StopFailMessage(s.SpinnerMessage) + if spinErr := s.Spinner.StopFail(); spinErr != nil { + return fmt.Errorf(text.SpinnerErrWrapper, spinErr, err) + } + } + + // Display the buffer stored output as we have an error. + fmt.Fprintf(s.Output, "%s", buf.String()) + + return err + } + + text.Output(output, divider) + return nil +} + +// Signal enables spawned subprocess to accept given signal. +func (s *Streaming) Signal(sig os.Signal) error { + if s.Process != nil { + err := s.Process.Signal(sig) + if err != nil { + return err + } + } + return nil +} + +// CommandOpts are arguments for executing a streaming command. +type CommandOpts struct { + // Args are the command positional arguments. + Args []string + // Command is the command to be executed. + Command string + // Env is the environment variables to set. + Env []string + // ErrLog provides an interface for recording errors to disk. + ErrLog fsterr.LogInterface + // Output is where to write output (e.g. stdout) + Output io.Writer + // Spinner is a specific spinner instance. + Spinner text.Spinner + // SpinnerMessage is the messaging to use. + SpinnerMessage string + // Timeout is the command timeout. + Timeout int + // Verbose outputs additional information. + Verbose bool +} + +// Command is an abstraction over a Streaming type. It is used by both the +// `compute init` and `compute build` commands to run post init/build scripts. +func Command(opts CommandOpts) error { + s := Streaming{ + Command: opts.Command, + Args: opts.Args, + Env: opts.Env, + Output: opts.Output, + Spinner: opts.Spinner, + SpinnerMessage: opts.SpinnerMessage, + Verbose: opts.Verbose, + } + if opts.Verbose { + s.ForceOutput = true + } + if opts.Timeout > 0 { + s.Timeout = time.Duration(opts.Timeout) * time.Second + } + if err := s.Exec(); err != nil { + opts.ErrLog.Add(err) + return err + } + return nil +} diff --git a/pkg/file/archive.go b/pkg/file/archive.go new file mode 100644 index 000000000..d93b8101e --- /dev/null +++ b/pkg/file/archive.go @@ -0,0 +1,157 @@ +package file + +import ( + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "strings" + + "github.com/mholt/archiver/v3" + + "github.com/fastly/cli/pkg/errors" +) + +// Archives is a collection of supported archive formats. +var Archives = []Archive{TarGz, Zip} + +// Archive represents the associated behaviour for a collection of files +// contained inside an archive format. +type Archive interface { + Extensions() []string + Extract() error + Filename() string + MimeTypes() []string + SetDestination(d string) + SetFilename(n string) +} + +// TarGz represents an instance of a tar.gz archive file. +var TarGz = &ArchiveGzip{ + ArchiveBase{ + Exts: []string{".tgz", ".gz"}, + Mimes: []string{"application/gzip", "application/x-gzip", "application/x-tar"}, + }, +} + +// Zip represents an instance of a zip archive file. +var Zip = &ArchiveZip{ + ArchiveBase{ + Exts: []string{".zip"}, + Mimes: []string{"application/zip", "application/x-zip"}, + }, +} + +// ArchiveGzip represents a container for the .tar.gz file format. +type ArchiveGzip struct { + ArchiveBase +} + +// ArchiveZip represents a container for the .zip file format. +type ArchiveZip struct { + ArchiveBase +} + +// ArchiveBase represents a container for a collection of files. +type ArchiveBase struct { + Dst string + Exts []string + File io.ReadSeeker + Mimes []string + Name string +} + +// Extensions returns the accepted file extensions. +func (a ArchiveBase) Extensions() []string { + return a.Exts +} + +// MimeTypes returns all valid mime types for the format. +func (a ArchiveBase) MimeTypes() []string { + return a.Mimes +} + +// Filename returns the file name. +func (a ArchiveBase) Filename() string { + return a.Name +} + +// SetDestination sets the destination for where files should be extracted. +func (a *ArchiveBase) SetDestination(d string) { + a.Dst = d +} + +// SetFilename sets the name of the local archive file. +// +// NOTE: This archive file is the 'container' of the archived files that will +// be extracted separately. +func (a *ArchiveBase) SetFilename(n string) { + a.Name = n +} + +// Extract all files and folders from the collection. +func (a ArchiveBase) Extract() error { + if err := archiver.Unarchive(a.Filename(), a.Dst); err != nil { + return fmt.Errorf("error extracting contents from archive: %w", err) + } + + if _, err := os.Stat("fastly.toml"); err == nil { + return nil + } + + // Looks like the package files are contained within a top-level directory + // that now need to be extracted. + wd, err := os.Getwd() + if err != nil { + return fmt.Errorf("error determining current directory: %w", err) + } + + var dirContentToMove string + + err = filepath.WalkDir(wd, func(path string, entry fs.DirEntry, err error) error { + // WalkDir() triggered an error + if err != nil { + return err + } + // We already check if the current directory had a manifest so skip it + if entry.IsDir() && path == wd { + return nil + } + // We expect there to be a directory that contains the manifest + if entry.IsDir() { + if _, err := os.Stat(filepath.Join(path, "fastly.toml")); err == nil { + dirContentToMove = path + return errors.ErrStopWalk + } + } + return nil + }) + + if err != nil && err != errors.ErrStopWalk { + return err + } + if dirContentToMove == "" { + return errors.ErrInvalidArchive + } + + files, err := filepath.Glob(filepath.Join(dirContentToMove, "*")) + if err != nil { + return err + } + + // Move files from within package directory into its parent directory + for _, path := range files { + dir, file := filepath.Split(path) + if strings.HasSuffix(dir, string(os.PathSeparator)) { + dir = dir[:len(dir)-1] + } + parent := filepath.Dir(dir) + err := os.Rename(path, filepath.Join(parent, file)) + if err != nil { + return err + } + } + + return os.RemoveAll(dirContentToMove) +} diff --git a/pkg/file/doc.go b/pkg/file/doc.go new file mode 100644 index 000000000..b521f01e6 --- /dev/null +++ b/pkg/file/doc.go @@ -0,0 +1,2 @@ +// Package file contains functions to handle different file formats. +package file diff --git a/pkg/filesystem/directory.go b/pkg/filesystem/directory.go index 6d8fff94f..ce65178cb 100644 --- a/pkg/filesystem/directory.go +++ b/pkg/filesystem/directory.go @@ -1,8 +1,10 @@ package filesystem import ( + "errors" "fmt" "io" + "io/fs" "os" "path/filepath" ) @@ -10,11 +12,11 @@ import ( // FileExists asserts whether a file path exists. func FileExists(path string) bool { _, err := os.Stat(path) - return !os.IsNotExist(err) + return !errors.Is(err, fs.ErrNotExist) } // CopyFile copies a file from src to dst. If src and dst files exist, and are -// the same, then return successfully. Otherise, attempt to copy the file +// the same, then return successfully. Otherwise, attempt to copy the file // contents from src to dst. The file will be created if it does not already // exist. If the destination file exists, all it's contents will be replaced by // the contents of the source file. @@ -29,13 +31,13 @@ func CopyFile(src, dst string) (err error) { if !ss.Mode().IsRegular() { // Cannot copy non-regular files (e.g., directories, // symlinks, devices, etc.) - return fmt.Errorf("non-regular source file: %s", src) + return fmt.Errorf("non-regular source file: %s", src) // #nosec G307 } // Get destination file stats. ds, err := os.Stat(dst) if err != nil { - if !os.IsNotExist(err) { + if !errors.Is(err, fs.ErrNotExist) { return fmt.Errorf("cannot read destination file: %s", dst) } } else { @@ -58,27 +60,34 @@ func CopyFile(src, dst string) (err error) { defer in.Close() // #nosec G307 // Create all directories of destination - if err = os.MkdirAll(filepath.Dir(dst), 0700); err != nil { + if err = os.MkdirAll(filepath.Dir(dst), 0o700); err != nil { return fmt.Errorf("creating destination directory: %w", err) } // Create destination file for writing. + // + // gosec flagged this: + // G304 (CWE-22): Potential file inclusion via variable + // + // Disabling as we require a user to configure their own environment. + /* #nosec */ out, err := os.Create(dst) if err != nil { return fmt.Errorf("error creating destination file: %w", err) } + defer func() { cerr := out.Close() if err == nil { err = cerr } }() + if _, err = io.Copy(out, in); err != nil { return fmt.Errorf("error copying file contents: %w", err) } - err = out.Sync() - return + return out.Sync() } // MakeDirectoryIfNotExists asserts whether a directory exists and makes it @@ -90,8 +99,8 @@ func MakeDirectoryIfNotExists(path string) error { return nil case err == nil && !fi.IsDir(): return fmt.Errorf("%s already exists as a regular file", path) - case os.IsNotExist(err): - return os.MkdirAll(path, 0750) + case errors.Is(err, fs.ErrNotExist): + return os.MkdirAll(path, 0o750) case err != nil: return err } diff --git a/pkg/filesystem/doc.go b/pkg/filesystem/doc.go new file mode 100644 index 000000000..a54471a14 --- /dev/null +++ b/pkg/filesystem/doc.go @@ -0,0 +1,2 @@ +// Package filesystem contains functions to handle file operations. +package filesystem diff --git a/pkg/filesystem/home.go b/pkg/filesystem/home.go new file mode 100644 index 000000000..8bee5bec1 --- /dev/null +++ b/pkg/filesystem/home.go @@ -0,0 +1,50 @@ +package filesystem + +import ( + "os" + "path/filepath" + "strings" +) + +const ( + // UnixHome is the home directory for a unix system. + UnixHome = "$HOME" + // UnixHomeShort is the 'short' home directory for a unix system. + UnixHomeShort = "~" + // WindowsHome is the home directory for a Windows system. + WindowsHome = "%USERPROFILE%" +) + +// ResolveAbs returns an absolute path with the user home directory resolved. +// +// EXAMPLE (unix): +// $HOME/.gitignore -> /Users//.gitignore +// ~/.gitignore -> /Users//.gitignore +// . +func ResolveAbs(path string) string { + var uhd string + if strings.HasPrefix(path, UnixHome) { + uhd = UnixHome + } + if strings.HasPrefix(path, UnixHomeShort) { + uhd = UnixHomeShort + } + if strings.HasPrefix(path, WindowsHome) { + uhd = WindowsHome + } + + if uhd != "" { + home, err := os.UserHomeDir() + if err != nil { + return path + } + path = strings.Replace(path, uhd, "", 1) + return filepath.Join(home, path) + } + + s, err := filepath.Abs(path) + if err != nil { + return path + } + return s +} diff --git a/pkg/fmt/doc.go b/pkg/fmt/doc.go new file mode 100644 index 000000000..62a036efb --- /dev/null +++ b/pkg/fmt/doc.go @@ -0,0 +1,2 @@ +// Package fmt contains helper functions for formatting text. +package fmt diff --git a/pkg/fmt/fmt.go b/pkg/fmt/fmt.go new file mode 100644 index 000000000..80c448d70 --- /dev/null +++ b/pkg/fmt/fmt.go @@ -0,0 +1,42 @@ +package fmt + +import ( + "bytes" + "encoding/json" + "fmt" + + "github.com/fastly/cli/pkg/text" +) + +// Success is a test helper used to generate output for asserting against. +func Success(format string, args ...any) string { + var b bytes.Buffer + text.Success(&b, format, args...) + return b.String() +} + +// Info is a test helper used to generate output for asserting against. +func Info(format string, args ...any) string { + var b bytes.Buffer + text.Info(&b, format, args...) + return b.String() +} + +// JSON decodes then re-encodes back to JSON, with indentation matching +// that of ../cmd/argparser.go's argparser.WriteJSON. +func JSON(format string, args ...any) string { + var r json.RawMessage + if err := json.Unmarshal([]byte(fmt.Sprintf(format, args...)), &r); err != nil { + panic(err) + } + return EncodeJSON(r) +} + +// EncodeJSON is a test helper that encodes any Go type into JSON. +func EncodeJSON(value any) string { + var b bytes.Buffer + enc := json.NewEncoder(&b) + enc.SetIndent("", " ") + _ = enc.Encode(value) + return b.String() +} diff --git a/pkg/github/doc.go b/pkg/github/doc.go new file mode 100644 index 000000000..c27834708 --- /dev/null +++ b/pkg/github/doc.go @@ -0,0 +1,3 @@ +// Package github contains functions for checking the latest software +// versions hosted by GitHub. +package github diff --git a/pkg/github/github.go b/pkg/github/github.go new file mode 100644 index 000000000..0679594dd --- /dev/null +++ b/pkg/github/github.go @@ -0,0 +1,502 @@ +package github + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "regexp" + "runtime" + "strings" + + "github.com/blang/semver" + "github.com/mholt/archiver/v3" + + "github.com/fastly/cli/pkg/api" + "github.com/fastly/cli/pkg/debug" + fstruntime "github.com/fastly/cli/pkg/runtime" +) + +const ( + // metadataURL takes a GitHub repo (e.g. cli or viceroy), an OS (e.g. darwin or linux), and an arch (e.g. amd64 or arm64). + metadataURL = "https://developer.fastly.com/api/internal/releases/meta/%s/%s/%s" +) + +// InstallDir represents the directory where the assets should be installed. +// +// NOTE: This is a package level variable as it makes testing the behaviour of +// the package easier because the test code can replace the value when running +// the test suite. +var InstallDir = func() string { + if dir, err := os.UserConfigDir(); err == nil { + return filepath.Join(dir, "fastly") + } + if dir, err := os.UserHomeDir(); err == nil { + return filepath.Join(dir, ".fastly") + } + panic("unable to deduce user config dir or user home dir") +}() + +// New returns a usable asset. +func New(opts Opts) *Asset { + binary := opts.Binary + if fstruntime.Windows && filepath.Ext(binary) == "" { + binary += ".exe" + } + + return &Asset{ + binary: binary, + debug: opts.DebugMode, + external: opts.External, + httpClient: opts.HTTPClient, + nested: opts.Nested, + org: opts.Org, + repo: opts.Repo, + versionRequested: opts.Version, + } +} + +// Opts represents options to be passed to NewGitHub. +type Opts struct { + // Binary is the name of the executable binary. + Binary string + // DebugMode indicates the user has set debug-mode. + DebugMode bool + // External indicates the repository is a non-Fastly repo. + // This means we need a custom metadata fetcher (i.e. dont use metadataURL). + External bool + // HTTPClient is able to make HTTP requests. + HTTPClient api.HTTPClient + // Nested indicates if the binary is at the root of the archive or not. + // e.g. wasm-tools archive contains a folder which contains the binary. + // Where as Viceroy and CLI archives directly contain the binary. + Nested bool + // Org is a GitHub organisation. + Org string + // Repo is a GitHub repository. + Repo string + // Version is the asset's release version to download. + // The value is the semver format (example: "0.1.0"). + // If not set, then the latest version is implied. + Version string +} + +// Asset is a versioner that uses Asset releases. +type Asset struct { + // binary is the name of the executable binary. + binary string + // debug indicates if the user is running in debug-mode. + debug bool + // external indicates the repository is a non-Fastly repo. + external bool + // httpClient is able to make HTTP requests. + httpClient api.HTTPClient + // nested indicates if the binary is at the root of the archive or not. + nested bool + // org is a GitHub organisation. + org string + // repo is a GitHub repository. + repo string + // url is the endpoint for downloading the release asset. + url string + // version is the release version of the asset. + version string + // versionRequested is the requested release version of the asset. + versionRequested string +} + +// BinaryName returns the configured binary output name. +// +// NOTE: For some operating systems this might include a file extension, such +// as .exe for Windows. +func (g Asset) BinaryName() string { + return g.binary +} + +// DownloadLatest retrieves the latest binary version. +func (g *Asset) DownloadLatest() (bin string, err error) { + endpoint, err := g.URL() + if err != nil { + return "", err + } + return g.Download(endpoint) +} + +// DownloadVersion retrieves the specified binary version. +func (g *Asset) DownloadVersion(version string) (bin string, err error) { + _, err = semver.Parse(version) + if err != nil { + return "", err + } + + endpoint, err := g.URL() + if err != nil { + return "", err + } + + endpoint = strings.ReplaceAll(endpoint, g.version, version) + + return g.Download(endpoint) +} + +// Download retrieves the binary archive format from the specified endpoint. +func (g *Asset) Download(endpoint string) (bin string, err error) { + req, err := http.NewRequest(http.MethodGet, endpoint, nil) + if err != nil { + return "", fmt.Errorf("failed to create a HTTP request: %w", err) + } + + if g.httpClient == nil { + g.httpClient = http.DefaultClient + } + if g.debug { + debug.DumpHTTPRequest(req) + } + res, err := g.httpClient.Do(req) + if g.debug { + debug.DumpHTTPResponse(res) + } + if err != nil { + return "", fmt.Errorf("failed to request GitHub release asset: %w", err) + } + if res.StatusCode != http.StatusOK { + return "", fmt.Errorf("failed to request GitHub release asset: %s", res.Status) + } + defer res.Body.Close() // #nosec G307 + + tmpDir, err := os.MkdirTemp("", "fastly-download") + if err != nil { + return "", fmt.Errorf("failed to create temp release directory: %w", err) + } + defer os.RemoveAll(tmpDir) + + assetBase := filepath.Base(endpoint) + archive, err := createArchive(assetBase, tmpDir, res.Body) + if err != nil { + return "", err + } + + extractedBinary, err := extractBinary(archive, g.binary, tmpDir, assetBase, g.nested) + if err != nil { + return "", err + } + + return moveExtractedBinary(g.binary, extractedBinary) +} + +// URL returns the downloadable asset URL if set, otherwise calls the API metadata endpoint. +func (g *Asset) URL() (url string, err error) { + if g.url != "" { + return g.url, nil + } + + m, err := g.metadata() + if err != nil { + return "", err + } + + g.url = m.URL + g.version = m.Version + + return g.url, nil +} + +// LatestVersion returns the asset LatestVersion if set, otherwise calls the API metadata endpoint. +func (g *Asset) LatestVersion() (version string, err error) { + if g.version != "" { + return g.version, nil + } + + m, err := g.metadata() + if err != nil { + return "", err + } + + g.url = m.URL + g.version = m.Version + + return g.version, nil +} + +// RequestedVersion returns the version of the asset defined in the fastly.toml. +// NOTE: This is only relevant for `compute serve` with viceroy_version pinning. +func (g *Asset) RequestedVersion() string { + return g.versionRequested +} + +// SetRequestedVersion sets the version of the asset to be downloaded. +// This is typically used by `compute serve` when an `--env` flag is set. +func (g *Asset) SetRequestedVersion(version string) { + g.versionRequested = version +} + +// metadata acquires GitHub metadata. +func (g *Asset) metadata() (m DevHubMetadata, err error) { + endpoint := fmt.Sprintf(metadataURL, g.repo, runtime.GOOS, runtime.GOARCH) + if g.external { + endpoint = fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", g.org, g.repo) + } + + req, err := http.NewRequest(http.MethodGet, endpoint, nil) + if err != nil { + return m, fmt.Errorf("failed to create a HTTP request: %w", err) + } + + if g.httpClient == nil { + g.httpClient = http.DefaultClient + } + if g.debug { + debug.DumpHTTPRequest(req) + } + res, err := g.httpClient.Do(req) + if g.debug { + debug.DumpHTTPResponse(res) + } + if err != nil { + return m, fmt.Errorf("failed to request GitHub metadata: %w", err) + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return m, fmt.Errorf("failed to request GitHub metadata: %s", res.Status) + } + + data, err := io.ReadAll(res.Body) + if err != nil { + return m, fmt.Errorf("failed to read GitHub's metadata response: %w", err) + } + + if g.external { + return g.parseExternalMetadata(data) + } + + err = json.Unmarshal(data, &m) + if err != nil { + return m, fmt.Errorf("failed to parse GitHub's metadata: %w", err) + } + + return m, nil +} + +// InstallPath returns the location of where the asset should be installed. +func (g *Asset) InstallPath() string { + return filepath.Join(InstallDir, g.BinaryName()) +} + +// DevHubMetadata represents the DevHub API response for software metadata. +type DevHubMetadata struct { + // URL is the endpoint for downloading the release asset. + URL string `json:"url"` + // Version is the release version of the asset (e.g. 10.1.0). + Version string `json:"version"` +} + +// AssetVersioner describes a source of CLI release artifacts. +type AssetVersioner interface { + // BinaryName returns the configured binary output name. + BinaryName() string + // Download downloads the asset from the specified endpoint. + Download(endpoint string) (bin string, err error) + // DownloadLatest downloads the latest version of the asset. + DownloadLatest() (bin string, err error) + // DownloadVersion downloads the specified version of the asset. + DownloadVersion(version string) (bin string, err error) + // InstallPath returns the location of where the binary should be installed. + InstallPath() string + // RequestedVersion returns the version defined in the fastly.toml file. + RequestedVersion() (version string) + // SetRequestedVersion sets the version of the asset to be downloaded. + SetRequestedVersion(version string) + // URL returns the asset URL if set, otherwise calls the API metadata endpoint. + URL() (url string, err error) + // LatestVersion returns the latest version. + LatestVersion() (version string, err error) +} + +// createArchive copies the DevHub response body data into a temporary archive +// file and returns the path to the file. +func createArchive(assetBase, tmpDir string, data io.ReadCloser) (path string, err error) { + // gosec flagged this: + // G304 (CWE-22): Potential file inclusion via variable + // + // Disabling as the inputs need to be dynamically determined. + // #nosec + archive, err := os.Create(filepath.Join(tmpDir, assetBase)) + if err != nil { + return "", fmt.Errorf("failed to create a temporary file: %w", err) + } + + _, err = io.Copy(archive, data) + if err != nil { + return "", fmt.Errorf("failed to copy the release asset response body: %w", err) + } + + if err := archive.Close(); err != nil { + return "", fmt.Errorf("failed to close release asset file: %w", err) + } + + return archive.Name(), nil +} + +// extractBinary extracts the executable binary (e.g. fastly, viceroy, +// wasm-tools) from the specified archive file, modifies its permissions and +// returns the path. +// +// NOTE: wasm-tools binary is within a nested directory. +// So we have to account for that by extracting the directory from the archive +// and then correct the path before attempting to modify the permissions. +func extractBinary(archive, binaryName, dst, assetBase string, nested bool) (bin string, err error) { + extractPath := binaryName + if nested { + extension := ".tar.gz" + if fstruntime.Windows { + extension = ".zip" + } + // e.g. extract the nested directory "wasm-tools-1.0.42-aarch64-macos" + // which itself contains the `wasm-tools` binary + extractPath = strings.TrimSuffix(assetBase, extension) + } + if err := archiver.Extract(archive, extractPath, dst); err != nil { + return "", fmt.Errorf("failed to extract binary: %w", err) + } + + extractedBinary := filepath.Join(dst, binaryName) + if nested { + // e.g. reference the binary from within the nested directory + extractedBinary = filepath.Join(dst, extractPath, binaryName) + } + + // G302 (CWE-276): Expect file permissions to be 0600 or less + // gosec flagged this: + // Disabling as the file was not executable without it and we need all users + // to be able to execute the binary. + /* #nosec */ + err = os.Chmod(extractedBinary, 0o755) + if err != nil { + return "", fmt.Errorf("failed to modify permissions on extracted binary: %w", err) + } + + return extractedBinary, nil +} + +// moveExtractedBinary creates a temporary file (representing the final +// executable binary) and moves the oldpath to it and returns its path. +func moveExtractedBinary(binName, oldpath string) (path string, err error) { + tmpBin, err := os.CreateTemp("", binName) + if err != nil { + return "", fmt.Errorf("failed to create temp file: %w", err) + } + + defer func(name string) { + if err != nil { + _ = os.Remove(name) + } + }(tmpBin.Name()) + + if err := tmpBin.Close(); err != nil { + return "", fmt.Errorf("failed to close temp file: %w", err) + } + + if err := os.Rename(oldpath, tmpBin.Name()); err != nil { + return "", fmt.Errorf("failed to rename release asset file: %w", err) + } + + return tmpBin.Name(), nil +} + +// SetBinPerms ensures 0777 perms are set on the binary. +func SetBinPerms(bin string) error { + // G302 (CWE-276): Expect file permissions to be 0600 or less + // gosec flagged this: + // Disabling as the file was not executable without it and we need all users + // to be able to execute the binary. + // #nosec + err := os.Chmod(bin, 0o777) + if err != nil { + return fmt.Errorf("error setting executable permissions for %s: %w", bin, err) + } + return nil +} + +// RawAsset represents a GitHub release asset. +type RawAsset struct { + // BrowserDownloadURL is a fully qualified URL to download the release asset. + BrowserDownloadURL string `json:"browser_download_url"` +} + +// Metadata represents the GitHub API metadata response for releases. +type Metadata struct { + // Name is the release name. + Name string `json:"name"` + // Assets a list of all available assets within the release. + Assets []RawAsset `json:"assets"` + + org, repo, binary string +} + +// Version parses a semver from the name field. +func (m Metadata) Version() string { + r := regexp.MustCompile(`[0-9]+\.[0-9]+\.[0-9]+(-(.*))?`) + return r.FindString(m.Name) +} + +// URL filters the assets for a platform correct asset. +// +// NOTE: This only works with wasm-tools naming conventions. +// If we add more tools to download in future then we can abstract as necessary. +func (m Metadata) URL() string { + platform := runtime.GOOS + if platform == "darwin" { + platform = "macos" + } + + arch := runtime.GOARCH + switch arch { + case "arm64": + arch = "aarch64" + case "amd64": + arch = "x86_64" + } + + extension := "tar.gz" + if fstruntime.Windows { + extension = "zip" + } + + for _, a := range m.Assets { + version := m.Version() + // NOTE: We use `m.repo` for wasm-tools instead of `m.binary`. + // This is because we append `.exe` to `m.binary` on Windows. + // Instead of filtering the extension we just use `m.repo` instead. + pattern := fmt.Sprintf("https://github.com/%s/%s/releases/download/v%s/%s-%s-%s-%s.%s", m.org, m.repo, version, m.repo, version, arch, platform, extension) + if matched, _ := regexp.MatchString(pattern, a.BrowserDownloadURL); matched { + return a.BrowserDownloadURL + } + } + + return "" +} + +// parseExternalMetadata takes the raw GitHub metadata and coerces it into a +// DevHub specific metadata format. +func (g *Asset) parseExternalMetadata(data []byte) (DevHubMetadata, error) { + var ( + dhm DevHubMetadata + m Metadata + ) + + err := json.Unmarshal(data, &m) + if err != nil { + return dhm, fmt.Errorf("failed to parse GitHub's metadata: %w", err) + } + + m.org = g.org + m.repo = g.repo + m.binary = g.binary + + dhm.Version = m.Version() + dhm.URL = m.URL() + + return dhm, nil +} diff --git a/pkg/github/github_test.go b/pkg/github/github_test.go new file mode 100644 index 000000000..5e39f705e --- /dev/null +++ b/pkg/github/github_test.go @@ -0,0 +1,70 @@ +package github + +import ( + "fmt" + "os" + "runtime" + "testing" + + fstruntime "github.com/fastly/cli/pkg/runtime" +) + +// TestDownloadArchiveExtract validates both Windows and Unix release assets. +func TestDownloadArchiveExtract(t *testing.T) { + scenarios := []struct { + Platform string + Arch string + Ext string + }{ + { + Platform: "darwin", + Arch: "arm64", + Ext: ".tar.gz", + }, + { + Platform: "darwin", + Arch: "amd64", + Ext: ".tar.gz", + }, + { + Platform: "windows", + Arch: "amd64", + Ext: ".zip", + }, + } + + for _, testcase := range scenarios { + name := fmt.Sprintf("%s_%s", testcase.Platform, testcase.Arch) + + t.Run(name, func(t *testing.T) { + // Avoid, for example, running the Windows OS scenario on non Windows OS. + // Otherwise, the Windows OS scenario would show on Darwin an error like: + // no asset found for your OS (darwin) and architecture (amd64) + if runtime.GOOS != testcase.Platform || runtime.GOARCH != testcase.Arch { + t.Skip() + } + + binary := "fastly" + if fstruntime.Windows { + binary += ".exe" + } + + a := Asset{ + binary: binary, + org: "fastly", + repo: "cli", + } + + // IMPORTANT: This is a real network end-to-end integration test. + // Meaning, we are making a real request to the DevHub endpoint. + bin, err := a.DownloadLatest() + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + if err := os.RemoveAll(bin); err != nil { + t.Fatalf("unexpected error: %s", err) + } + }) + } +} diff --git a/pkg/github/testdata/fastly_v0.0.1_darwin-amd64.tar.gz b/pkg/github/testdata/fastly_v0.0.1_darwin-amd64.tar.gz new file mode 100644 index 000000000..eaba74b1e Binary files /dev/null and b/pkg/github/testdata/fastly_v0.0.1_darwin-amd64.tar.gz differ diff --git a/pkg/github/testdata/fastly_v0.0.1_darwin-arm64.tar.gz b/pkg/github/testdata/fastly_v0.0.1_darwin-arm64.tar.gz new file mode 100644 index 000000000..a4cb21be2 Binary files /dev/null and b/pkg/github/testdata/fastly_v0.0.1_darwin-arm64.tar.gz differ diff --git a/pkg/github/testdata/fastly_v0.0.1_windows-amd64.zip b/pkg/github/testdata/fastly_v0.0.1_windows-amd64.zip new file mode 100644 index 000000000..cfe70b818 Binary files /dev/null and b/pkg/github/testdata/fastly_v0.0.1_windows-amd64.zip differ diff --git a/pkg/global/doc.go b/pkg/global/doc.go new file mode 100644 index 000000000..08ccb9a68 --- /dev/null +++ b/pkg/global/doc.go @@ -0,0 +1,3 @@ +// Package global exposes a type to contain global'ish data. +// Effectively we use it to avoid an unfortunate import loop issue. +package global diff --git a/pkg/global/global.go b/pkg/global/global.go new file mode 100644 index 000000000..b2b03e488 --- /dev/null +++ b/pkg/global/global.go @@ -0,0 +1,240 @@ +package global + +import ( + "fmt" + "io" + + "github.com/fastly/cli/pkg/api" + "github.com/fastly/cli/pkg/auth" + "github.com/fastly/cli/pkg/config" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/github" + "github.com/fastly/cli/pkg/lookup" + "github.com/fastly/cli/pkg/manifest" +) + +// DefaultAPIEndpoint is the default Fastly API endpoint. +const DefaultAPIEndpoint = "https://api.fastly.com" + +// DefaultAccountEndpoint is the default Fastly Accounts endpoint. +const DefaultAccountEndpoint = "https://accounts.fastly.com" + +// APIClientFactory creates a Fastly API client (modeled as an api.Interface) +// from a user-provided API token. It exists as a type in order to parameterize +// the Run helper with it: in the real CLI, we can use NewClient from the Fastly +// API client library via RealClient; in tests, we can provide a mock API +// interface via MockClient. +type APIClientFactory func(token, apiEndpoint string, debugMode bool) (api.Interface, error) + +// Versioners represents all supported versioner types. +type Versioners struct { + CLI github.AssetVersioner + Viceroy github.AssetVersioner + WasmTools github.AssetVersioner +} + +// Data holds global-ish configuration data from all sources: environment +// variables, config files, and flags. It has methods to give each parameter to +// the components that need it, including the place the parameter came from, +// which is a requirement. +// +// If the same parameter is defined in multiple places, it is resolved according +// to the following priority order: the config file (lowest priority), env vars, +// and then explicit flags (highest priority). +// +// This package and its types are only meant for parameters that are applicable +// to most/all subcommands (e.g. API token) and are consistent for a given user +// (e.g. an email address). Otherwise, parameters should be defined in specific +// command structs, and parsed as flags. +type Data struct { + // APIClient is a Fastly API client instance. + APIClient api.Interface + // APIClientFactory is a factory function for creating an api.Interface type. + APIClientFactory APIClientFactory + // Args are the command line arguments provided by the user. + Args []string + // AuthServer is an instance of the authentication server type. + // Used for interacting with Fastly's SSO/OAuth authentication provider. + AuthServer auth.Runner + // Config is an instance of the CLI configuration data. + Config config.File + // ConfigPath is the path to the CLI's application configuration. + ConfigPath string + // Env is all the data that is provided by the environment. + Env config.Environment + // ErrLog provides an interface for recording errors to disk. + ErrLog fsterr.LogInterface + // ExecuteWasmTools is a function that executes the wasm-tools binary. + ExecuteWasmTools func(bin string, args []string, global *Data) error + // Flags are all the global CLI flags. + Flags Flags + // HTTPClient is a HTTP client. + HTTPClient api.HTTPClient + // Input is the standard input for accepting input from the user. + Input io.Reader + // Manifest represents the fastly.toml manifest file and associated flags. + Manifest *manifest.Data + // Opener is a function that can open a browser window. + Opener func(string) error + // Output is the output for displaying information (typically os.Stdout) + Output io.Writer + // RTSClient is a Fastly API client instance for the Real Time Stats endpoints. + RTSClient api.RealtimeStatsInterface + // SkipAuthPrompt is used to indicate to the `sso` command that the + // interactive prompt can be skipped. This is for scenarios where the command + // is executed directly by the user. + SkipAuthPrompt bool + // Versioners contains multiple software versioning checkers. + // e.g. Check for latest CLI or Viceroy version. + Versioners Versioners +} + +// Profile identifies the current profile (if any). +func (d *Data) Profile() (string, *config.Profile, error) { + var ( + profileData *config.Profile + found bool + name, profileName string + ) + switch { + case d.Flags.Profile != "": // --profile + profileName = d.Flags.Profile + case d.Manifest.File.Profile != "": // `profile` field in fastly.toml + profileName = d.Manifest.File.Profile + default: + profileName = "default" // fallback to locating the default profile + } + for name, profileData = range d.Config.Profiles { + if (profileName == "default" && profileData.Default) || name == profileName { + // Once we find the default profile we can update the variable to be the + // associated profile name so later on we can use that information to + // update the specific profile. + if profileName == "default" { + profileName = name + } + found = true + break + } + } + if !found { + return "", nil, fmt.Errorf("failed to locate '%s' profile", profileName) + } + return profileName, profileData, nil +} + +// Token yields the Fastly API token. +// +// Order of precedence: +// - The --token flag. +// - The FASTLY_API_TOKEN environment variable. +// - The --profile flag's associated token. +// - The `profile` manifest field's associated profile token. +// - The 'default' profile associated token (if there is one). +func (d *Data) Token() (string, lookup.Source) { + // --token + if d.Flags.Token != "" { + return d.Flags.Token, lookup.SourceFlag + } + + // FASTLY_API_TOKEN + if d.Env.APIToken != "" { + return d.Env.APIToken, lookup.SourceEnvironment + } + + // --profile + if d.Flags.Profile != "" { + for k, v := range d.Config.Profiles { + if k == d.Flags.Profile { + return v.Token, lookup.SourceFile + } + } + } + + // `profile` field in fastly.toml + if d.Manifest.File.Profile != "" { + for k, v := range d.Config.Profiles { + if k == d.Manifest.File.Profile { + return v.Token, lookup.SourceFile + } + } + } + + // [profile] section in app config + for _, v := range d.Config.Profiles { + if v.Default { + return v.Token, lookup.SourceFile + } + } + + return "", lookup.SourceUndefined +} + +// Verbose yields the verbose flag, which can only be set via flags. +func (d *Data) Verbose() bool { + return d.Flags.Verbose +} + +// APIEndpoint yields the API endpoint. +func (d *Data) APIEndpoint() (string, lookup.Source) { + if d.Flags.APIEndpoint != "" { + return d.Flags.APIEndpoint, lookup.SourceFlag + } + + if d.Env.APIEndpoint != "" { + return d.Env.APIEndpoint, lookup.SourceEnvironment + } + + if d.Config.Fastly.APIEndpoint != DefaultAPIEndpoint && d.Config.Fastly.APIEndpoint != "" { + return d.Config.Fastly.APIEndpoint, lookup.SourceFile + } + + return DefaultAPIEndpoint, lookup.SourceDefault // this method should not fail +} + +// AccountEndpoint yields the Accounts endpoint. +func (d *Data) AccountEndpoint() (string, lookup.Source) { + if d.Flags.AccountEndpoint != "" { + return d.Flags.AccountEndpoint, lookup.SourceFlag + } + + if d.Env.AccountEndpoint != "" { + return d.Env.AccountEndpoint, lookup.SourceEnvironment + } + + if d.Config.Fastly.AccountEndpoint != DefaultAccountEndpoint && d.Config.Fastly.AccountEndpoint != "" { + return d.Config.Fastly.AccountEndpoint, lookup.SourceFile + } + + return DefaultAccountEndpoint, lookup.SourceDefault // this method should not fail +} + +// Flags represents all of the configuration parameters that can be set with +// explicit flags. Consumers should bind their flag values to these fields +// directly. +// +// IMPORTANT: Kingpin doesn't support global flags. +// We hack a solution in ../app/run.go (`configureKingpin` function). +type Flags struct { + // AcceptDefaults auto-resolves prompts with a default defined. + AcceptDefaults bool + // AccountEndpoint is the authentication host address. + AccountEndpoint string + // APIEndpoint is the Fastly API address. + APIEndpoint string + // AutoYes auto-resolves Yes/No prompts by answering "Yes". + AutoYes bool + // Debug enables the CLI's debug mode. + Debug bool + // NonInteractive auto-resolves all prompts. + NonInteractive bool + // Profile indicates the profile to use (consequently the 'token' used). + Profile string + // Quiet silences all output except direct command output. + Quiet bool + // SSO enables SSO authentication tokens for the current profile. + SSO bool + // Token is an override for a profile (when passed SSO is disabled). + Token string + // Verbose prints additional output. + Verbose bool +} diff --git a/pkg/healthcheck/create.go b/pkg/healthcheck/create.go deleted file mode 100644 index 9b37ed798..000000000 --- a/pkg/healthcheck/create.go +++ /dev/null @@ -1,61 +0,0 @@ -package healthcheck - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// CreateCommand calls the Fastly API to create healthchecks. -type CreateCommand struct { - common.Base - manifest manifest.Data - Input fastly.CreateHealthCheckInput -} - -// NewCreateCommand returns a usable command registered under the parent. -func NewCreateCommand(parent common.Registerer, globals *config.Data) *CreateCommand { - var c CreateCommand - c.Globals = globals - c.CmdClause = parent.Command("create", "Create a healthcheck on a Fastly service version").Alias("add") - - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Input.ServiceVersion) - - c.CmdClause.Flag("name", "Healthcheck name").Short('n').Required().StringVar(&c.Input.Name) - c.CmdClause.Flag("comment", "A descriptive note").StringVar(&c.Input.Comment) - c.CmdClause.Flag("method", "Which HTTP method to use").StringVar(&c.Input.Method) - c.CmdClause.Flag("host", "Which host to check").StringVar(&c.Input.Host) - c.CmdClause.Flag("path", "The path to check").StringVar(&c.Input.Path) - c.CmdClause.Flag("http-version", "Whether to use version 1.0 or 1.1 HTTP").StringVar(&c.Input.HTTPVersion) - c.CmdClause.Flag("timeout", "Timeout in milliseconds").UintVar(&c.Input.Timeout) - c.CmdClause.Flag("check-interval", "How often to run the healthcheck in milliseconds").UintVar(&c.Input.CheckInterval) - c.CmdClause.Flag("expected-response", "The status code expected from the host").UintVar(&c.Input.ExpectedResponse) - c.CmdClause.Flag("window", "The number of most recent healthcheck queries to keep for this healthcheck").UintVar(&c.Input.Window) - c.CmdClause.Flag("threshold", "How many healthchecks must succeed to be considered healthy").UintVar(&c.Input.Threshold) - c.CmdClause.Flag("initial", "When loading a config, the initial number of probes to be seen as OK").UintVar(&c.Input.Initial) - - return &c -} - -// Exec invokes the application logic for the command. -func (c *CreateCommand) Exec(in io.Reader, out io.Writer) error { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - c.Input.ServiceID = serviceID - - h, err := c.Globals.Client.CreateHealthCheck(&c.Input) - if err != nil { - return err - } - - text.Success(out, "Created healthcheck %s (service %s version %d)", h.Name, h.ServiceID, h.ServiceVersion) - return nil -} diff --git a/pkg/healthcheck/delete.go b/pkg/healthcheck/delete.go deleted file mode 100644 index 562adbbea..000000000 --- a/pkg/healthcheck/delete.go +++ /dev/null @@ -1,48 +0,0 @@ -package healthcheck - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// DeleteCommand calls the Fastly API to delete healthchecks. -type DeleteCommand struct { - common.Base - manifest manifest.Data - Input fastly.DeleteHealthCheckInput -} - -// NewDeleteCommand returns a usable command registered under the parent. -func NewDeleteCommand(parent common.Registerer, globals *config.Data) *DeleteCommand { - var c DeleteCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("delete", "Delete a healthcheck on a Fastly service version").Alias("remove") - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Input.ServiceVersion) - c.CmdClause.Flag("name", "Healthcheck name").Short('n').Required().StringVar(&c.Input.Name) - return &c -} - -// Exec invokes the application logic for the command. -func (c *DeleteCommand) Exec(in io.Reader, out io.Writer) error { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - c.Input.ServiceID = serviceID - - if err := c.Globals.Client.DeleteHealthCheck(&c.Input); err != nil { - return err - } - - text.Success(out, "Deleted healthcheck %s (service %s version %d)", c.Input.Name, c.Input.ServiceID, c.Input.ServiceVersion) - return nil -} diff --git a/pkg/healthcheck/describe.go b/pkg/healthcheck/describe.go deleted file mode 100644 index a2d167794..000000000 --- a/pkg/healthcheck/describe.go +++ /dev/null @@ -1,53 +0,0 @@ -package healthcheck - -import ( - "fmt" - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// DescribeCommand calls the Fastly API to describe a healthcheck. -type DescribeCommand struct { - common.Base - manifest manifest.Data - Input fastly.GetHealthCheckInput -} - -// NewDescribeCommand returns a usable command registered under the parent. -func NewDescribeCommand(parent common.Registerer, globals *config.Data) *DescribeCommand { - var c DescribeCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("describe", "Show detailed information about a healthcheck on a Fastly service version").Alias("get") - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Input.ServiceVersion) - c.CmdClause.Flag("name", "Name of healthcheck").Short('n').Required().StringVar(&c.Input.Name) - return &c -} - -// Exec invokes the application logic for the command. -func (c *DescribeCommand) Exec(in io.Reader, out io.Writer) error { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - c.Input.ServiceID = serviceID - - healthCheck, err := c.Globals.Client.GetHealthCheck(&c.Input) - if err != nil { - return err - } - - fmt.Fprintf(out, "Service ID: %s\n", healthCheck.ServiceID) - fmt.Fprintf(out, "Version: %d\n", healthCheck.ServiceVersion) - text.PrintHealthCheck(out, "", healthCheck) - - return nil -} diff --git a/pkg/healthcheck/healthcheck_test.go b/pkg/healthcheck/healthcheck_test.go deleted file mode 100644 index a6ee8d765..000000000 --- a/pkg/healthcheck/healthcheck_test.go +++ /dev/null @@ -1,382 +0,0 @@ -package healthcheck_test - -import ( - "bytes" - "errors" - "io" - "net/http" - "strings" - "testing" - - "github.com/fastly/cli/pkg/app" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/mock" - "github.com/fastly/cli/pkg/testutil" - "github.com/fastly/cli/pkg/update" - "github.com/fastly/go-fastly/v3/fastly" -) - -func TestHealthCheckCreate(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"healthcheck", "create", "--version", "1", "--service-id", "123"}, - api: mock.API{CreateHealthCheckFn: createHealthCheckOK}, - wantError: "error parsing arguments: required flag --name not provided", - }, - { - args: []string{"healthcheck", "create", "--service-id", "123", "--version", "1", "--name", "www.test.com"}, - api: mock.API{CreateHealthCheckFn: createHealthCheckError}, - wantError: errTest.Error(), - }, - { - args: []string{"healthcheck", "create", "--service-id", "123", "--version", "1", "--name", "www.test.com"}, - api: mock.API{CreateHealthCheckFn: createHealthCheckOK}, - wantOutput: "Created healthcheck www.test.com (service 123 version 1)", - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertStringContains(t, out.String(), testcase.wantOutput) - }) - } -} - -func TestHealthCheckList(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"healthcheck", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListHealthChecksFn: listHealthChecksOK}, - wantOutput: listHealthChecksShortOutput, - }, - { - args: []string{"healthcheck", "list", "--service-id", "123", "--version", "1", "--verbose"}, - api: mock.API{ListHealthChecksFn: listHealthChecksOK}, - wantOutput: listHealthChecksVerboseOutput, - }, - { - args: []string{"healthcheck", "list", "--service-id", "123", "--version", "1", "-v"}, - api: mock.API{ListHealthChecksFn: listHealthChecksOK}, - wantOutput: listHealthChecksVerboseOutput, - }, - { - args: []string{"healthcheck", "--verbose", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListHealthChecksFn: listHealthChecksOK}, - wantOutput: listHealthChecksVerboseOutput, - }, - { - args: []string{"-v", "healthcheck", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListHealthChecksFn: listHealthChecksOK}, - wantOutput: listHealthChecksVerboseOutput, - }, - { - args: []string{"healthcheck", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListHealthChecksFn: listHealthChecksError}, - wantError: errTest.Error(), - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertString(t, testcase.wantOutput, out.String()) - }) - } -} - -func TestHealthCheckDescribe(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"healthcheck", "describe", "--service-id", "123", "--version", "1"}, - api: mock.API{GetHealthCheckFn: getHealthCheckOK}, - wantError: "error parsing arguments: required flag --name not provided", - }, - { - args: []string{"healthcheck", "describe", "--service-id", "123", "--version", "1", "--name", "www.test.com"}, - api: mock.API{GetHealthCheckFn: getHealthCheckError}, - wantError: errTest.Error(), - }, - { - args: []string{"healthcheck", "describe", "--service-id", "123", "--version", "1", "--name", "www.test.com"}, - api: mock.API{GetHealthCheckFn: getHealthCheckOK}, - wantOutput: describeHealthCheckOutput, - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertString(t, testcase.wantOutput, out.String()) - }) - } -} - -func TestHealthCheckUpdate(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"healthcheck", "update", "--service-id", "123", "--version", "2", "--new-name", "www.test.com", "--comment", ""}, - api: mock.API{UpdateHealthCheckFn: updateHealthCheckOK}, - wantError: "error parsing arguments: required flag --name not provided", - }, - { - args: []string{"healthcheck", "update", "--service-id", "123", "--version", "2", "--name", "www.test.com", "--new-name", "www.example.com"}, - api: mock.API{UpdateHealthCheckFn: updateHealthCheckOK}, - }, - { - args: []string{"healthcheck", "update", "--service-id", "123", "--version", "1", "--name", "www.test.com", "--new-name", "www.example.com"}, - api: mock.API{UpdateHealthCheckFn: updateHealthCheckError}, - wantError: errTest.Error(), - }, - { - args: []string{"healthcheck", "update", "--service-id", "123", "--version", "1", "--name", "www.test.com", "--new-name", "www.example.com"}, - api: mock.API{UpdateHealthCheckFn: updateHealthCheckOK}, - wantOutput: "Updated healthcheck www.example.com (service 123 version 1)", - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertStringContains(t, out.String(), testcase.wantOutput) - }) - } -} - -func TestHealthCheckDelete(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"healthcheck", "delete", "--service-id", "123", "--version", "1"}, - api: mock.API{DeleteHealthCheckFn: deleteHealthCheckOK}, - wantError: "error parsing arguments: required flag --name not provided", - }, - { - args: []string{"healthcheck", "delete", "--service-id", "123", "--version", "1", "--name", "www.test.com"}, - api: mock.API{DeleteHealthCheckFn: deleteHealthCheckError}, - wantError: errTest.Error(), - }, - { - args: []string{"healthcheck", "delete", "--service-id", "123", "--version", "1", "--name", "www.test.com"}, - api: mock.API{DeleteHealthCheckFn: deleteHealthCheckOK}, - wantOutput: "Deleted healthcheck www.test.com (service 123 version 1)", - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertStringContains(t, out.String(), testcase.wantOutput) - }) - } -} - -var errTest = errors.New("fixture error") - -func createHealthCheckOK(i *fastly.CreateHealthCheckInput) (*fastly.HealthCheck, error) { - return &fastly.HealthCheck{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: i.Name, - Comment: i.Comment, - Host: "www.test.com", - Path: "/health", - }, nil -} - -func createHealthCheckError(i *fastly.CreateHealthCheckInput) (*fastly.HealthCheck, error) { - return nil, errTest -} - -func listHealthChecksOK(i *fastly.ListHealthChecksInput) ([]*fastly.HealthCheck, error) { - return []*fastly.HealthCheck{ - { - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "test", - Comment: "test", - Method: "HEAD", - Host: "www.test.com", - Path: "/health", - }, - { - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "example", - Comment: "example", - Method: "HEAD", - Host: "www.example.com", - Path: "/health", - }, - }, nil -} - -func listHealthChecksError(i *fastly.ListHealthChecksInput) ([]*fastly.HealthCheck, error) { - return nil, errTest -} - -var listHealthChecksShortOutput = strings.TrimSpace(` -SERVICE VERSION NAME METHOD HOST PATH -123 1 test HEAD www.test.com /health -123 1 example HEAD www.example.com /health -`) + "\n" - -var listHealthChecksVerboseOutput = strings.Join([]string{ - "Fastly API token not provided", - "Fastly API endpoint: https://api.fastly.com", - "Service ID: 123", - "Version: 1", - " Healthcheck 1/2", - " Name: test", - " Comment: test", - " Method: HEAD", - " Host: www.test.com", - " Path: /health", - " HTTP version: ", - " Timeout: 0", - " Check interval: 0", - " Expected response: 0", - " Window: 0", - " Threshold: 0", - " Initial: 0", - " Healthcheck 2/2", - " Name: example", - " Comment: example", - " Method: HEAD", - " Host: www.example.com", - " Path: /health", - " HTTP version: ", - " Timeout: 0", - " Check interval: 0", - " Expected response: 0", - " Window: 0", - " Threshold: 0", - " Initial: 0", -}, "\n") + "\n\n" - -func getHealthCheckOK(i *fastly.GetHealthCheckInput) (*fastly.HealthCheck, error) { - return &fastly.HealthCheck{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "test", - Method: "HEAD", - Host: "www.test.com", - Path: "/healthcheck", - Comment: "test", - }, nil -} - -func getHealthCheckError(i *fastly.GetHealthCheckInput) (*fastly.HealthCheck, error) { - return nil, errTest -} - -var describeHealthCheckOutput = strings.Join([]string{ - "Service ID: 123", - "Version: 1", - "Name: test", - "Comment: test", - "Method: HEAD", - "Host: www.test.com", - "Path: /healthcheck", - "HTTP version: ", - "Timeout: 0", - "Check interval: 0", - "Expected response: 0", - "Window: 0", - "Threshold: 0", - "Initial: 0", -}, "\n") + "\n" - -func updateHealthCheckOK(i *fastly.UpdateHealthCheckInput) (*fastly.HealthCheck, error) { - return &fastly.HealthCheck{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: *i.NewName, - }, nil -} - -func updateHealthCheckError(i *fastly.UpdateHealthCheckInput) (*fastly.HealthCheck, error) { - return nil, errTest -} - -func deleteHealthCheckOK(i *fastly.DeleteHealthCheckInput) error { - return nil -} - -func deleteHealthCheckError(i *fastly.DeleteHealthCheckInput) error { - return errTest -} diff --git a/pkg/healthcheck/list.go b/pkg/healthcheck/list.go deleted file mode 100644 index b2293c732..000000000 --- a/pkg/healthcheck/list.go +++ /dev/null @@ -1,66 +0,0 @@ -package healthcheck - -import ( - "fmt" - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// ListCommand calls the Fastly API to list healthchecks. -type ListCommand struct { - common.Base - manifest manifest.Data - Input fastly.ListHealthChecksInput -} - -// NewListCommand returns a usable command registered under the parent. -func NewListCommand(parent common.Registerer, globals *config.Data) *ListCommand { - var c ListCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("list", "List healthchecks on a Fastly service version") - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Input.ServiceVersion) - return &c -} - -// Exec invokes the application logic for the command. -func (c *ListCommand) Exec(in io.Reader, out io.Writer) error { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - c.Input.ServiceID = serviceID - - healthChecks, err := c.Globals.Client.ListHealthChecks(&c.Input) - if err != nil { - return err - } - - if !c.Globals.Verbose() { - tw := text.NewTable(out) - tw.AddHeader("SERVICE", "VERSION", "NAME", "METHOD", "HOST", "PATH") - for _, healthCheck := range healthChecks { - tw.AddLine(healthCheck.ServiceID, healthCheck.ServiceVersion, healthCheck.Name, healthCheck.Method, healthCheck.Host, healthCheck.Path) - } - tw.Print() - return nil - } - - fmt.Fprintf(out, "Service ID: %s\n", c.Input.ServiceID) - fmt.Fprintf(out, "Version: %d\n", c.Input.ServiceVersion) - for i, healthCheck := range healthChecks { - fmt.Fprintf(out, "\tHealthcheck %d/%d\n", i+1, len(healthChecks)) - text.PrintHealthCheck(out, "\t\t", healthCheck) - } - fmt.Fprintln(out) - - return nil -} diff --git a/pkg/healthcheck/root.go b/pkg/healthcheck/root.go deleted file mode 100644 index 4db5092af..000000000 --- a/pkg/healthcheck/root.go +++ /dev/null @@ -1,28 +0,0 @@ -package healthcheck - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/config" -) - -// RootCommand is the parent command for all subcommands in this package. -// It should be installed under the primary root command. -type RootCommand struct { - common.Base - // no flags -} - -// NewRootCommand returns a new command registered in the parent. -func NewRootCommand(parent common.Registerer, globals *config.Data) *RootCommand { - var c RootCommand - c.Globals = globals - c.CmdClause = parent.Command("healthcheck", "Manipulate Fastly service version healthchecks") - return &c -} - -// Exec implements the command interface. -func (c *RootCommand) Exec(in io.Reader, out io.Writer) error { - panic("unreachable") -} diff --git a/pkg/healthcheck/update.go b/pkg/healthcheck/update.go deleted file mode 100644 index b9f741c00..000000000 --- a/pkg/healthcheck/update.go +++ /dev/null @@ -1,125 +0,0 @@ -package healthcheck - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// UpdateCommand calls the Fastly API to update healthchecks. -type UpdateCommand struct { - common.Base - manifest manifest.Data - input fastly.UpdateHealthCheckInput - - NewName common.OptionalString - Comment common.OptionalString - Method common.OptionalString - Host common.OptionalString - Path common.OptionalString - HTTPVersion common.OptionalString - Timeout common.OptionalUint - CheckInterval common.OptionalUint - ExpectedResponse common.OptionalUint - Window common.OptionalUint - Threshold common.OptionalUint - Initial common.OptionalUint -} - -// NewUpdateCommand returns a usable command registered under the parent. -func NewUpdateCommand(parent common.Registerer, globals *config.Data) *UpdateCommand { - var c UpdateCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("update", "Update a healthcheck on a Fastly service version") - - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.input.ServiceVersion) - c.CmdClause.Flag("name", "Healthcheck name").Short('n').Required().StringVar(&c.input.Name) - - c.CmdClause.Flag("new-name", "Healthcheck name").Action(c.NewName.Set).StringVar(&c.NewName.Value) - c.CmdClause.Flag("comment", "A descriptive note").Action(c.Comment.Set).StringVar(&c.Comment.Value) - c.CmdClause.Flag("method", "Which HTTP method to use").Action(c.Method.Set).StringVar(&c.Method.Value) - c.CmdClause.Flag("host", "Which host to check").Action(c.Host.Set).StringVar(&c.Host.Value) - c.CmdClause.Flag("path", "The path to check").Action(c.Path.Set).StringVar(&c.Path.Value) - c.CmdClause.Flag("http-version", "Whether to use version 1.0 or 1.1 HTTP").Action(c.HTTPVersion.Set).StringVar(&c.HTTPVersion.Value) - c.CmdClause.Flag("timeout", "Timeout in milliseconds").Action(c.Timeout.Set).UintVar(&c.Timeout.Value) - c.CmdClause.Flag("check-interval", "How often to run the healthcheck in milliseconds").Action(c.CheckInterval.Set).UintVar(&c.CheckInterval.Value) - c.CmdClause.Flag("expected-response", "The status code expected from the host").Action(c.ExpectedResponse.Set).UintVar(&c.ExpectedResponse.Value) - c.CmdClause.Flag("window", "The number of most recent healthcheck queries to keep for this healthcheck").Action(c.Window.Set).UintVar(&c.Window.Value) - c.CmdClause.Flag("threshold", "How many healthchecks must succeed to be considered healthy").Action(c.Threshold.Set).UintVar(&c.Threshold.Value) - c.CmdClause.Flag("initial", "When loading a config, the initial number of probes to be seen as OK").Action(c.Initial.Set).UintVar(&c.Initial.Value) - - return &c -} - -// Exec invokes the application logic for the command. -func (c *UpdateCommand) Exec(in io.Reader, out io.Writer) error { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - c.input.ServiceID = serviceID - - if c.NewName.WasSet { - c.input.NewName = fastly.String(c.NewName.Value) - } - - if c.Comment.WasSet { - c.input.Comment = fastly.String(c.Comment.Value) - } - - if c.Method.WasSet { - c.input.Method = fastly.String(c.Method.Value) - } - - if c.Host.WasSet { - c.input.Host = fastly.String(c.Host.Value) - } - - if c.Path.WasSet { - c.input.Path = fastly.String(c.Path.Value) - } - - if c.HTTPVersion.WasSet { - c.input.HTTPVersion = fastly.String(c.HTTPVersion.Value) - } - - if c.Timeout.WasSet { - c.input.Timeout = fastly.Uint(c.Timeout.Value) - } - - if c.CheckInterval.WasSet { - c.input.CheckInterval = fastly.Uint(c.CheckInterval.Value) - } - - if c.ExpectedResponse.WasSet { - c.input.ExpectedResponse = fastly.Uint(c.ExpectedResponse.Value) - } - - if c.Window.WasSet { - c.input.Window = fastly.Uint(c.Window.Value) - } - - if c.Threshold.WasSet { - c.input.Threshold = fastly.Uint(c.Threshold.Value) - } - - if c.Initial.WasSet { - c.input.Initial = fastly.Uint(c.Initial.Value) - } - - h, err := c.Globals.Client.UpdateHealthCheck(&c.input) - if err != nil { - return err - } - - text.Success(out, "Updated healthcheck %s (service %s version %d)", h.Name, h.ServiceID, h.ServiceVersion) - return nil -} diff --git a/pkg/internal/beacon/beacon.go b/pkg/internal/beacon/beacon.go new file mode 100644 index 000000000..524a36915 --- /dev/null +++ b/pkg/internal/beacon/beacon.go @@ -0,0 +1,59 @@ +package beacon + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + + "github.com/fastly/cli/pkg/api/undocumented" + "github.com/fastly/cli/pkg/global" +) + +// Common event statuses or results. +const ( + StatusSuccess = "success" + StatusFail = "fail" +) + +// Event represents something that happened that we need to signal to +// the notification relay. +type Event struct { + Name string `json:"event"` + Status string `json:"status"` + Payload map[string]any `json:"payload"` +} + +const beaconNotify = "/cli/%s/notify" + +// Notify emits an Event for the given serviceID to the notification +// relay. +func Notify(g *global.Data, serviceID string, e Event) error { + headers := []undocumented.HTTPHeader{ + { + Key: "Content-Type", + Value: "application/json", + }, + } + + body, err := json.Marshal(e) + if err != nil { + return err + } + + co := undocumented.CallOptions{ + APIEndpoint: "https://fastly-notification-relay.edgecompute.app", + Path: fmt.Sprintf(beaconNotify, serviceID), + Method: http.MethodPost, + HTTPHeaders: headers, + HTTPClient: g.HTTPClient, + Body: bytes.NewReader(body), + } + + _, err = undocumented.Call(co) + if err != nil { + return err + } + + return nil +} diff --git a/pkg/internal/beacon/beacon_test.go b/pkg/internal/beacon/beacon_test.go new file mode 100644 index 000000000..57146e1ee --- /dev/null +++ b/pkg/internal/beacon/beacon_test.go @@ -0,0 +1,63 @@ +package beacon_test + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "strings" + "testing" + + "github.com/fastly/cli/pkg/internal/beacon" + "github.com/fastly/cli/pkg/testutil" +) + +func TestNotify(t *testing.T) { + args := testutil.SplitArgs("compute deploy") + out := bytes.NewBuffer(nil) + g := testutil.MockGlobalData(args, out) + m := &mockHTTPClient{ + resp: &http.Response{ + StatusCode: http.StatusNoContent, + Status: http.StatusText(http.StatusNoContent), + Body: io.NopCloser(strings.NewReader("")), + }, + } + g.HTTPClient = m + + err := beacon.Notify(g, "service-id", beacon.Event{ + Name: "test-event", + Status: beacon.StatusSuccess, + }) + + testutil.AssertNoError(t, err) + testutil.AssertEqual(t, "/cli/service-id/notify", m.req.URL.Path) + testutil.AssertEqual(t, "fastly-notification-relay.edgecompute.app", m.req.URL.Host) + + rawData, err := io.ReadAll(m.req.Body) + testutil.AssertNoError(t, err) + defer m.req.Body.Close() + + var data map[string]any + err = json.Unmarshal(rawData, &data) + testutil.AssertNoError(t, err) + + name, ok := data["event"].(string) + testutil.AssertBool(t, true, ok) + testutil.AssertEqual(t, "test-event", name) + + result, ok := data["status"].(string) + testutil.AssertBool(t, true, ok) + testutil.AssertEqual(t, "success", result) +} + +type mockHTTPClient struct { + req *http.Request + resp *http.Response + err error +} + +func (m *mockHTTPClient) Do(r *http.Request) (*http.Response, error) { + m.req = r + return m.resp, m.err +} diff --git a/pkg/internal/beacon/doc.go b/pkg/internal/beacon/doc.go new file mode 100644 index 000000000..f6a576eee --- /dev/null +++ b/pkg/internal/beacon/doc.go @@ -0,0 +1,4 @@ +// Package beacon sends notifications of events to the +// fastly-notification-relay, which we use to synchronize state between +// the UI and the CLI. +package beacon diff --git a/pkg/logging/azureblob/azureblob_integration_test.go b/pkg/logging/azureblob/azureblob_integration_test.go deleted file mode 100644 index b7f3ef835..000000000 --- a/pkg/logging/azureblob/azureblob_integration_test.go +++ /dev/null @@ -1,490 +0,0 @@ -package azureblob_test - -import ( - "bytes" - "errors" - "io" - "net/http" - "strings" - "testing" - - "github.com/fastly/cli/pkg/app" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/mock" - "github.com/fastly/cli/pkg/testutil" - "github.com/fastly/cli/pkg/update" - "github.com/fastly/go-fastly/v3/fastly" -) - -func TestBlobStorageCreate(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "azureblob", "create", "--service-id", "123", "--version", "1", "--name", "log", "--account-name", "account", "--sas-token", "abc"}, - wantError: "error parsing arguments: required flag --container not provided", - }, - { - args: []string{"logging", "azureblob", "create", "--service-id", "123", "--version", "1", "--name", "log", "--container", "log", "--sas-token", "abc"}, - wantError: "error parsing arguments: required flag --account-name not provided", - }, - { - args: []string{"logging", "azureblob", "create", "--service-id", "123", "--version", "1", "--name", "log", "--account-name", "account", "--container", "log"}, - wantError: "error parsing arguments: required flag --sas-token not provided", - }, - { - args: []string{"logging", "azureblob", "create", "--service-id", "123", "--version", "1", "--name", "log", "--account-name", "account", "--container", "log", "--sas-token", "abc"}, - api: mock.API{CreateBlobStorageFn: createBlobStorageOK}, - wantOutput: "Created Azure Blob Storage logging endpoint log (service 123 version 1)", - }, - { - args: []string{"logging", "azureblob", "create", "--service-id", "123", "--version", "1", "--name", "log", "--account-name", "account", "--container", "log", "--sas-token", "abc"}, - api: mock.API{CreateBlobStorageFn: createBlobStorageError}, - wantError: errTest.Error(), - }, - { - args: []string{"logging", "azureblob", "create", "--service-id", "123", "--version", "1", "--name", "log", "--account-name", "account", "--container", "log", "--sas-token", "abc", "--compression-codec", "zstd", "--gzip-level", "9"}, - api: mock.API{CreateBlobStorageFn: createBlobStorageError}, - wantError: "error parsing arguments: the --compression-codec flag is mutually exclusive with the --gzip-level flag", - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertStringContains(t, out.String(), testcase.wantOutput) - }) - } -} - -func TestBlobStorageList(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "azureblob", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListBlobStoragesFn: listBlobStoragesOK}, - wantOutput: listBlobStoragesShortOutput, - }, - { - args: []string{"logging", "azureblob", "list", "--service-id", "123", "--version", "1", "--verbose"}, - api: mock.API{ListBlobStoragesFn: listBlobStoragesOK}, - wantOutput: listBlobStoragesVerboseOutput, - }, - { - args: []string{"logging", "azureblob", "list", "--service-id", "123", "--version", "1", "-v"}, - api: mock.API{ListBlobStoragesFn: listBlobStoragesOK}, - wantOutput: listBlobStoragesVerboseOutput, - }, - { - args: []string{"logging", "azureblob", "--verbose", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListBlobStoragesFn: listBlobStoragesOK}, - wantOutput: listBlobStoragesVerboseOutput, - }, - { - args: []string{"logging", "-v", "azureblob", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListBlobStoragesFn: listBlobStoragesOK}, - wantOutput: listBlobStoragesVerboseOutput, - }, - { - args: []string{"logging", "azureblob", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListBlobStoragesFn: listBlobStoragesError}, - wantError: errTest.Error(), - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertString(t, testcase.wantOutput, out.String()) - }) - } -} - -func TestBlobStorageDescribe(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "azureblob", "describe", "--service-id", "123", "--version", "1"}, - wantError: "error parsing arguments: required flag --name not provided", - }, - { - args: []string{"logging", "azureblob", "describe", "--service-id", "123", "--version", "1", "--name", "logs"}, - api: mock.API{GetBlobStorageFn: getBlobStorageError}, - wantError: errTest.Error(), - }, - { - args: []string{"logging", "azureblob", "describe", "--service-id", "123", "--version", "1", "--name", "logs"}, - api: mock.API{GetBlobStorageFn: getBlobStorageOK}, - wantOutput: describeBlobStorageOutput, - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertString(t, testcase.wantOutput, out.String()) - }) - } -} - -func TestBlobStorageUpdate(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "azureblob", "update", "--service-id", "123", "--version", "1", "--new-name", "log"}, - wantError: "error parsing arguments: required flag --name not provided", - }, - { - args: []string{"logging", "azureblob", "update", "--service-id", "123", "--version", "1", "--name", "logs", "--new-name", "log"}, - api: mock.API{UpdateBlobStorageFn: updateBlobStorageError}, - wantError: errTest.Error(), - }, - { - args: []string{"logging", "azureblob", "update", "--service-id", "123", "--version", "1", "--name", "logs", "--new-name", "log"}, - api: mock.API{UpdateBlobStorageFn: updateBlobStorageOK}, - wantOutput: "Updated Azure Blob Storage logging endpoint log (service 123 version 1)", - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertStringContains(t, out.String(), testcase.wantOutput) - }) - } -} - -func TestBlobStorageDelete(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "azureblob", "delete", "--service-id", "123", "--version", "1"}, - wantError: "error parsing arguments: required flag --name not provided", - }, - { - args: []string{"logging", "azureblob", "delete", "--service-id", "123", "--version", "1", "--name", "logs"}, - api: mock.API{DeleteBlobStorageFn: deleteBlobStorageError}, - wantError: errTest.Error(), - }, - { - args: []string{"logging", "azureblob", "delete", "--service-id", "123", "--version", "1", "--name", "logs"}, - api: mock.API{DeleteBlobStorageFn: deleteBlobStorageOK}, - wantOutput: "Deleted Azure Blob Storage logging endpoint logs (service 123 version 1)", - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertStringContains(t, out.String(), testcase.wantOutput) - }) - } -} - -var errTest = errors.New("fixture error") - -func createBlobStorageOK(i *fastly.CreateBlobStorageInput) (*fastly.BlobStorage, error) { - s := fastly.BlobStorage{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "log", - Path: "/logs", - AccountName: "account", - Container: "container", - SASToken: "token", - Period: 3600, - TimestampFormat: "%Y-%m-%dT%H:%M:%S.000", - PublicKey: pgpPublicKey(), - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - MessageType: "classic", - Placement: "none", - ResponseCondition: "Prevent default logging", - CompressionCodec: "zstd", - } - - return &s, nil -} - -func createBlobStorageError(i *fastly.CreateBlobStorageInput) (*fastly.BlobStorage, error) { - return nil, errTest -} - -func listBlobStoragesOK(i *fastly.ListBlobStoragesInput) ([]*fastly.BlobStorage, error) { - return []*fastly.BlobStorage{ - { - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "logs", - Path: "/logs", - AccountName: "account", - Container: "container", - SASToken: "token", - Period: 3600, - TimestampFormat: "%Y-%m-%dT%H:%M:%S.000", - PublicKey: pgpPublicKey(), - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - MessageType: "classic", - Placement: "none", - ResponseCondition: "Prevent default logging", - CompressionCodec: "zstd", - }, - { - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "analytics", - AccountName: "account", - Container: "analytics", - SASToken: "token", - Path: "/logs", - Period: 86400, - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - MessageType: "classic", - ResponseCondition: "Prevent default logging", - TimestampFormat: "%Y-%m-%dT%H:%M:%S.000", - Placement: "none", - PublicKey: pgpPublicKey(), - CompressionCodec: "zstd", - }, - }, nil -} - -func listBlobStoragesError(i *fastly.ListBlobStoragesInput) ([]*fastly.BlobStorage, error) { - return nil, errTest -} - -var listBlobStoragesShortOutput = strings.TrimSpace(` -SERVICE VERSION NAME -123 1 logs -123 1 analytics -`) + "\n" - -var listBlobStoragesVerboseOutput = strings.TrimSpace(` -Fastly API token not provided -Fastly API endpoint: https://api.fastly.com -Service ID: 123 -Version: 1 - BlobStorage 1/2 - Service ID: 123 - Version: 1 - Name: logs - Container: container - Account name: account - SAS token: token - Path: /logs - Period: 3600 - GZip level: 0 - Format: %h %l %u %t "%r" %>s %b - Format version: 2 - Response condition: Prevent default logging - Message type: classic - Timestamp format: %Y-%m-%dT%H:%M:%S.000 - Placement: none - Public key: `+pgpPublicKey()+` - File max bytes: 0 - Compression codec: zstd - BlobStorage 2/2 - Service ID: 123 - Version: 1 - Name: analytics - Container: analytics - Account name: account - SAS token: token - Path: /logs - Period: 86400 - GZip level: 0 - Format: %h %l %u %t "%r" %>s %b - Format version: 2 - Response condition: Prevent default logging - Message type: classic - Timestamp format: %Y-%m-%dT%H:%M:%S.000 - Placement: none - Public key: `+pgpPublicKey()+` - File max bytes: 0 - Compression codec: zstd -`) + "\n\n" - -func getBlobStorageOK(i *fastly.GetBlobStorageInput) (*fastly.BlobStorage, error) { - return &fastly.BlobStorage{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "logs", - Container: "container", - AccountName: "account", - SASToken: "token", - Path: "/logs", - Period: 3600, - GzipLevel: 0, - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - ResponseCondition: "Prevent default logging", - MessageType: "classic", - TimestampFormat: "%Y-%m-%dT%H:%M:%S.000", - Placement: "none", - PublicKey: pgpPublicKey(), - CompressionCodec: "zstd", - }, nil -} - -func getBlobStorageError(i *fastly.GetBlobStorageInput) (*fastly.BlobStorage, error) { - return nil, errTest -} - -var describeBlobStorageOutput = strings.TrimSpace(` -Service ID: 123 -Version: 1 -Name: logs -Container: container -Account name: account -SAS token: token -Path: /logs -Period: 3600 -GZip level: 0 -Format: %h %l %u %t "%r" %>s %b -Format version: 2 -Response condition: Prevent default logging -Message type: classic -Timestamp format: %Y-%m-%dT%H:%M:%S.000 -Placement: none -Public key: `+pgpPublicKey()+` -File max bytes: 0 -Compression codec: zstd -`) + "\n" - -func updateBlobStorageOK(i *fastly.UpdateBlobStorageInput) (*fastly.BlobStorage, error) { - return &fastly.BlobStorage{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "log", - Container: "container", - AccountName: "account", - SASToken: "token", - Path: "/logs", - Period: 3600, - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - ResponseCondition: "Prevent default logging", - MessageType: "classic", - TimestampFormat: "%Y-%m-%dT%H:%M:%S.000", - Placement: "none", - PublicKey: pgpPublicKey(), - CompressionCodec: "zstd", - }, nil -} - -func updateBlobStorageError(i *fastly.UpdateBlobStorageInput) (*fastly.BlobStorage, error) { - return nil, errTest -} - -func deleteBlobStorageOK(i *fastly.DeleteBlobStorageInput) error { - return nil -} - -func deleteBlobStorageError(i *fastly.DeleteBlobStorageInput) error { - return errTest -} - -// pgpPublicKey returns a PEM encoded PGP public key suitable for testing. -func pgpPublicKey() string { - return strings.TrimSpace(`-----BEGIN PGP PUBLIC KEY BLOCK----- -mQENBFyUD8sBCACyFnB39AuuTygseek+eA4fo0cgwva6/FSjnWq7riouQee8GgQ/ -ibXTRyv4iVlwI12GswvMTIy7zNvs1R54i0qvsLr+IZ4GVGJqs6ZJnvQcqe3xPoR4 -8AnBfw90o32r/LuHf6QCJXi+AEu35koNlNAvLJ2B+KACaNB7N0EeWmqpV/1V2k9p -lDYk+th7LcCuaFNGqKS/PrMnnMqR6VDLCjHhNx4KR79b0Twm/2qp6an3hyNRu8Gn -dwxpf1/BUu3JWf+LqkN4Y3mbOmSUL3MaJNvyQguUzTfS0P0uGuBDHrJCVkMZCzDB -89ag55jCPHyGeHBTd02gHMWzsg3WMBWvCsrzABEBAAG0JXRlcnJhZm9ybSAodGVz -dCkgPHRlc3RAdGVycmFmb3JtLmNvbT6JAU4EEwEIADgWIQSHYyc6Kj9l6HzQsau6 -vFFc9jxV/wUCXJQPywIbAwULCQgHAgYVCgkICwIEFgIDAQIeAQIXgAAKCRC6vFFc -9jxV/815CAClb32OxV7wG01yF97TzlyTl8TnvjMtoG29Mw4nSyg+mjM3b8N7iXm9 -OLX59fbDAWtBSldSZE22RXd3CvlFOG/EnKBXSjBtEqfyxYSnyOPkMPBYWGL/ApkX -SvPYJ4LKdvipYToKFh3y9kk2gk1DcDBDyaaHvR+3rv1u3aoy7/s2EltAfDS3ZQIq -7/cWTLJml/lleeB/Y6rPj8xqeCYhE5ahw9gsV/Mdqatl24V9Tks30iijx0Hhw+Gx -kATUikMGr2GDVqoIRga5kXI7CzYff4rkc0Twn47fMHHHe/KY9M2yVnMHUXmAZwbG -M1cMI/NH1DjevCKdGBLcRJlhuLPKF/anuQENBFyUD8sBCADIpd7r7GuPd6n/Ikxe -u6h7umV6IIPoAm88xCYpTbSZiaK30Svh6Ywra9jfE2KlU9o6Y/art8ip0VJ3m07L -4RSfSpnzqgSwdjSq5hNour2Fo/BzYhK7yaz2AzVSbe33R0+RYhb4b/6N+bKbjwGF -ftCsqVFMH+PyvYkLbvxyQrHlA9woAZaNThI1ztO5rGSnGUR8xt84eup28WIFKg0K -UEGUcTzz+8QGAwAra+0ewPXo/AkO+8BvZjDidP417u6gpBHOJ9qYIcO9FxHeqFyu -YrjlrxowEgXn5wO8xuNz6Vu1vhHGDHGDsRbZF8pv1d5O+0F1G7ttZ2GRRgVBZPwi -kiyRABEBAAGJATYEGAEIACAWIQSHYyc6Kj9l6HzQsau6vFFc9jxV/wUCXJQPywIb -DAAKCRC6vFFc9jxV/9YOCACe8qmOSnKQpQfW+PqYOqo3dt7JyweTs3FkD6NT8Zml -dYy/vkstbTjPpX6aTvUZjkb46BVi7AOneVHpD5GBqvRsZ9iVgDYHaehmLCdKiG5L -3Tp90NN+QY5WDbsGmsyk6+6ZMYejb4qYfweQeduOj27aavCJdLkCYMoRKfcFYI8c -FaNmEfKKy/r1PO20NXEG6t9t05K/frHy6ZG8bCNYdpagfFVot47r9JaQqWlTNtIR -5+zkkSq/eG9BEtRij3a6cTdQbktdBzx2KBeI0PYc1vlZR0LpuFKZqY9vlE6vTGLR -wMfrTEOvx0NxUM3rpaCgEmuWbB1G1Hu371oyr4srrr+N -=28dr ------END PGP PUBLIC KEY BLOCK----- -`) -} diff --git a/pkg/logging/azureblob/azureblob_test.go b/pkg/logging/azureblob/azureblob_test.go deleted file mode 100644 index c93596200..000000000 --- a/pkg/logging/azureblob/azureblob_test.go +++ /dev/null @@ -1,264 +0,0 @@ -package azureblob - -import ( - "strings" - "testing" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/mock" - "github.com/fastly/cli/pkg/testutil" - "github.com/fastly/go-fastly/v3/fastly" -) - -func TestCreateBlobStorageInput(t *testing.T) { - for _, testcase := range []struct { - name string - cmd *CreateCommand - want *fastly.CreateBlobStorageInput - wantError string - }{ - { - name: "required values set flag serviceID", - cmd: createCommandRequired(), - want: &fastly.CreateBlobStorageInput{ - ServiceID: "123", - ServiceVersion: 2, - Name: "logs", - AccountName: "account", - Container: "container", - SASToken: "token", - }, - }, - { - name: "all values set flag serviceID", - cmd: createCommandAll(), - want: &fastly.CreateBlobStorageInput{ - ServiceID: "123", - ServiceVersion: 2, - Name: "logs", - Container: "container", - AccountName: "account", - SASToken: "token", - Path: "/log", - Period: 3600, - GzipLevel: 0, - Format: `%h %l %u %t "%r" %>s %b`, - MessageType: "classic", - FormatVersion: 2, - ResponseCondition: "Prevent default logging", - TimestampFormat: "%Y-%m-%dT%H:%M:%S.000", - Placement: "none", - PublicKey: pgpPublicKey(), - CompressionCodec: "zstd", - }, - }, - { - name: "error missing serviceID", - cmd: createCommandMissingServiceID(), - want: nil, - wantError: errors.ErrNoServiceID.Error(), - }, - } { - t.Run(testcase.name, func(t *testing.T) { - have, err := testcase.cmd.createInput() - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertEqual(t, testcase.want, have) - }) - } -} - -func TestUpdateBlobStorageInput(t *testing.T) { - for _, testcase := range []struct { - name string - cmd *UpdateCommand - api mock.API - want *fastly.UpdateBlobStorageInput - wantError string - }{ - { - name: "all values set flag serviceID", - cmd: updateCommandAll(), - api: mock.API{GetBlobStorageFn: getBlobStorageOK}, - want: &fastly.UpdateBlobStorageInput{ - ServiceID: "123", - ServiceVersion: 2, - Name: "logs", - NewName: fastly.String("new1"), - Container: fastly.String("new2"), - AccountName: fastly.String("new3"), - SASToken: fastly.String("new4"), - Path: fastly.String("new5"), - Period: fastly.Uint(3601), - GzipLevel: fastly.Uint(0), - Format: fastly.String("new6"), - FormatVersion: fastly.Uint(3), - ResponseCondition: fastly.String("new7"), - MessageType: fastly.String("new8"), - TimestampFormat: fastly.String("new9"), - Placement: fastly.String("new10"), - PublicKey: fastly.String("new11"), - CompressionCodec: fastly.String("new12"), - }, - }, - { - name: "no updates", - cmd: updateCommandNoUpdates(), - api: mock.API{GetBlobStorageFn: getBlobStorageOK}, - want: &fastly.UpdateBlobStorageInput{ - ServiceID: "123", - ServiceVersion: 2, - Name: "logs", - }, - }, - { - name: "error missing serviceID", - cmd: updateCommandMissingServiceID(), - want: nil, - wantError: errors.ErrNoServiceID.Error(), - }, - } { - t.Run(testcase.name, func(t *testing.T) { - testcase.cmd.Base.Globals.Client = testcase.api - - have, err := testcase.cmd.createInput() - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertEqual(t, testcase.want, have) - }) - } -} - -func createCommandRequired() *CreateCommand { - return &CreateCommand{ - manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, - EndpointName: "logs", - Version: 2, - Container: "container", - AccountName: "account", - SASToken: "token", - } -} - -func createCommandAll() *CreateCommand { - return &CreateCommand{ - manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, - EndpointName: "logs", - Version: 2, - Container: "container", - AccountName: "account", - SASToken: "token", - Path: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "/log"}, - Period: common.OptionalUint{Optional: common.Optional{WasSet: true}, Value: 3600}, - Format: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: `%h %l %u %t "%r" %>s %b`}, - FormatVersion: common.OptionalUint{Optional: common.Optional{WasSet: true}, Value: 2}, - ResponseCondition: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "Prevent default logging"}, - TimestampFormat: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "%Y-%m-%dT%H:%M:%S.000"}, - Placement: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "none"}, - MessageType: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "classic"}, - PublicKey: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: pgpPublicKey()}, - CompressionCodec: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "zstd"}, - } -} - -func createCommandMissingServiceID() *CreateCommand { - res := createCommandAll() - res.manifest = manifest.Data{} - return res -} - -func updateCommandNoUpdates() *UpdateCommand { - return &UpdateCommand{ - Base: common.Base{Globals: &config.Data{Client: nil}}, - manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, - EndpointName: "logs", - Version: 2, - } -} - -func updateCommandAll() *UpdateCommand { - return &UpdateCommand{ - Base: common.Base{Globals: &config.Data{Client: nil}}, - manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, - EndpointName: "logs", - Version: 2, - NewName: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new1"}, - Container: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new2"}, - AccountName: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new3"}, - SASToken: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new4"}, - Path: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new5"}, - Period: common.OptionalUint{Optional: common.Optional{WasSet: true}, Value: 3601}, - GzipLevel: common.OptionalUint{Optional: common.Optional{WasSet: true}, Value: 0}, - Format: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new6"}, - FormatVersion: common.OptionalUint{Optional: common.Optional{WasSet: true}, Value: 3}, - ResponseCondition: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new7"}, - MessageType: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new8"}, - TimestampFormat: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new9"}, - Placement: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new10"}, - PublicKey: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new11"}, - CompressionCodec: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new12"}, - } -} - -func updateCommandMissingServiceID() *UpdateCommand { - res := updateCommandAll() - res.manifest = manifest.Data{} - return res -} - -func getBlobStorageOK(i *fastly.GetBlobStorageInput) (*fastly.BlobStorage, error) { - return &fastly.BlobStorage{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "logs", - Path: "/log", - AccountName: "account", - Container: "container", - SASToken: "token", - Period: 3600, - TimestampFormat: "%Y-%m-%dT%H:%M:%S.000", - GzipLevel: 0, - PublicKey: pgpPublicKey(), - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - MessageType: "classic", - Placement: "none", - ResponseCondition: "Prevent default logging", - CompressionCodec: "zstd", - }, nil -} - -// pgpPublicKey returns a PEM encoded PGP public key suitable for testing. -func pgpPublicKey() string { - return strings.TrimSpace(`-----BEGIN PGP PUBLIC KEY BLOCK----- -mQENBFyUD8sBCACyFnB39AuuTygseek+eA4fo0cgwva6/FSjnWq7riouQee8GgQ/ -ibXTRyv4iVlwI12GswvMTIy7zNvs1R54i0qvsLr+IZ4GVGJqs6ZJnvQcqe3xPoR4 -8AnBfw90o32r/LuHf6QCJXi+AEu35koNlNAvLJ2B+KACaNB7N0EeWmqpV/1V2k9p -lDYk+th7LcCuaFNGqKS/PrMnnMqR6VDLCjHhNx4KR79b0Twm/2qp6an3hyNRu8Gn -dwxpf1/BUu3JWf+LqkN4Y3mbOmSUL3MaJNvyQguUzTfS0P0uGuBDHrJCVkMZCzDB -89ag55jCPHyGeHBTd02gHMWzsg3WMBWvCsrzABEBAAG0JXRlcnJhZm9ybSAodGVz -dCkgPHRlc3RAdGVycmFmb3JtLmNvbT6JAU4EEwEIADgWIQSHYyc6Kj9l6HzQsau6 -vFFc9jxV/wUCXJQPywIbAwULCQgHAgYVCgkICwIEFgIDAQIeAQIXgAAKCRC6vFFc -9jxV/815CAClb32OxV7wG01yF97TzlyTl8TnvjMtoG29Mw4nSyg+mjM3b8N7iXm9 -OLX59fbDAWtBSldSZE22RXd3CvlFOG/EnKBXSjBtEqfyxYSnyOPkMPBYWGL/ApkX -SvPYJ4LKdvipYToKFh3y9kk2gk1DcDBDyaaHvR+3rv1u3aoy7/s2EltAfDS3ZQIq -7/cWTLJml/lleeB/Y6rPj8xqeCYhE5ahw9gsV/Mdqatl24V9Tks30iijx0Hhw+Gx -kATUikMGr2GDVqoIRga5kXI7CzYff4rkc0Twn47fMHHHe/KY9M2yVnMHUXmAZwbG -M1cMI/NH1DjevCKdGBLcRJlhuLPKF/anuQENBFyUD8sBCADIpd7r7GuPd6n/Ikxe -u6h7umV6IIPoAm88xCYpTbSZiaK30Svh6Ywra9jfE2KlU9o6Y/art8ip0VJ3m07L -4RSfSpnzqgSwdjSq5hNour2Fo/BzYhK7yaz2AzVSbe33R0+RYhb4b/6N+bKbjwGF -ftCsqVFMH+PyvYkLbvxyQrHlA9woAZaNThI1ztO5rGSnGUR8xt84eup28WIFKg0K -UEGUcTzz+8QGAwAra+0ewPXo/AkO+8BvZjDidP417u6gpBHOJ9qYIcO9FxHeqFyu -YrjlrxowEgXn5wO8xuNz6Vu1vhHGDHGDsRbZF8pv1d5O+0F1G7ttZ2GRRgVBZPwi -kiyRABEBAAGJATYEGAEIACAWIQSHYyc6Kj9l6HzQsau6vFFc9jxV/wUCXJQPywIb -DAAKCRC6vFFc9jxV/9YOCACe8qmOSnKQpQfW+PqYOqo3dt7JyweTs3FkD6NT8Zml -dYy/vkstbTjPpX6aTvUZjkb46BVi7AOneVHpD5GBqvRsZ9iVgDYHaehmLCdKiG5L -3Tp90NN+QY5WDbsGmsyk6+6ZMYejb4qYfweQeduOj27aavCJdLkCYMoRKfcFYI8c -FaNmEfKKy/r1PO20NXEG6t9t05K/frHy6ZG8bCNYdpagfFVot47r9JaQqWlTNtIR -5+zkkSq/eG9BEtRij3a6cTdQbktdBzx2KBeI0PYc1vlZR0LpuFKZqY9vlE6vTGLR -wMfrTEOvx0NxUM3rpaCgEmuWbB1G1Hu371oyr4srrr+N -=28dr ------END PGP PUBLIC KEY BLOCK----- -`) -} diff --git a/pkg/logging/azureblob/create.go b/pkg/logging/azureblob/create.go deleted file mode 100644 index 7b5f77544..000000000 --- a/pkg/logging/azureblob/create.go +++ /dev/null @@ -1,160 +0,0 @@ -package azureblob - -import ( - "fmt" - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// CreateCommand calls the Fastly API to create an Azure Blob Storage logging endpoint. -type CreateCommand struct { - common.Base - manifest manifest.Data - - // required - EndpointName string // Can't shadow common.Base method Name(). - Version int - Container string - AccountName string - SASToken string - - // optional - Path common.OptionalString - Period common.OptionalUint - GzipLevel common.OptionalUint - MessageType common.OptionalString - Format common.OptionalString - FormatVersion common.OptionalUint - ResponseCondition common.OptionalString - TimestampFormat common.OptionalString - Placement common.OptionalString - PublicKey common.OptionalString - FileMaxBytes common.OptionalUint - CompressionCodec common.OptionalString -} - -// NewCreateCommand returns a usable command registered under the parent. -func NewCreateCommand(parent common.Registerer, globals *config.Data) *CreateCommand { - var c CreateCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("create", "Create an Azure Blob Storage logging endpoint on a Fastly service version").Alias("add") - - c.CmdClause.Flag("name", "The name of the Azure Blob Storage logging object. Used as a primary key for API access").Short('n').Required().StringVar(&c.EndpointName) - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Version) - c.CmdClause.Flag("container", "The name of the Azure Blob Storage container in which to store logs").Required().StringVar(&c.Container) - c.CmdClause.Flag("account-name", "The unique Azure Blob Storage namespace in which your data objects are stored").Required().StringVar(&c.AccountName) - c.CmdClause.Flag("sas-token", "The Azure shared access signature providing write access to the blob service objects. Be sure to update your token before it expires or the logging functionality will not work").Required().StringVar(&c.SASToken) - - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("path", "The path to upload logs to").Action(c.Path.Set).StringVar(&c.Path.Value) - c.CmdClause.Flag("period", "How frequently log files are finalized so they can be available for reading (in seconds, default 3600)").Action(c.Period.Set).UintVar(&c.Period.Value) - c.CmdClause.Flag("gzip-level", "What level of GZIP encoding to have when dumping logs (default 0, no compression)").Action(c.GzipLevel.Set).UintVar(&c.GzipLevel.Value) - c.CmdClause.Flag("format", "Apache style log formatting").Action(c.Format.Set).StringVar(&c.Format.Value) - c.CmdClause.Flag("message-type", "How the message should be formatted. One of: classic (default), loggly, logplex or blank").Action(c.MessageType.Set).StringVar(&c.MessageType.Value) - c.CmdClause.Flag("format-version", "The version of the custom logging format used for the configured endpoint. Can be either 2 (default) or 1").Action(c.FormatVersion.Set).UintVar(&c.FormatVersion.Value) - c.CmdClause.Flag("response-condition", "The name of an existing condition in the configured endpoint, or leave blank to always execute").Action(c.ResponseCondition.Set).StringVar(&c.ResponseCondition.Value) - c.CmdClause.Flag("timestamp-format", `strftime specified timestamp formatting (default "%Y-%m-%dT%H:%M:%S.000")`).Action(c.TimestampFormat.Set).StringVar(&c.TimestampFormat.Value) - c.CmdClause.Flag("placement", "Where in the generated VCL the logging call should be placed, overriding any format_version default. Can be none or waf_debug").Action(c.Placement.Set).StringVar(&c.Placement.Value) - c.CmdClause.Flag("public-key", "A PGP public key that Fastly will use to encrypt your log files before writing them to disk").Action(c.PublicKey.Set).StringVar(&c.PublicKey.Value) - c.CmdClause.Flag("file-max-bytes", "The maximum size of a log file in bytes").Action(c.FileMaxBytes.Set).UintVar(&c.FileMaxBytes.Value) - c.CmdClause.Flag("compression-codec", `The codec used for compression of your logs. Valid values are zstd, snappy, and gzip. If the specified codec is "gzip", gzip_level will default to 3. To specify a different level, leave compression_codec blank and explicitly set the level using gzip_level. Specifying both compression_codec and gzip_level in the same API request will result in an error.`).Action(c.CompressionCodec.Set).StringVar(&c.CompressionCodec.Value) - - return &c -} - -// createInput transforms values parsed from CLI flags into an object to be used by the API client library. -func (c *CreateCommand) createInput() (*fastly.CreateBlobStorageInput, error) { - var input fastly.CreateBlobStorageInput - - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return nil, errors.ErrNoServiceID - } - - input.ServiceID = serviceID - input.ServiceVersion = c.Version - input.Name = c.EndpointName - input.Container = c.Container - input.AccountName = c.AccountName - input.SASToken = c.SASToken - - // The following blocks enforces the mutual exclusivity of the - // CompressionCodec and GzipLevel flags. - if c.CompressionCodec.WasSet && c.GzipLevel.WasSet { - return nil, fmt.Errorf("error parsing arguments: the --compression-codec flag is mutually exclusive with the --gzip-level flag") - } - - if c.Path.WasSet { - input.Path = c.Path.Value - } - - if c.Period.WasSet { - input.Period = c.Period.Value - } - - if c.GzipLevel.WasSet { - input.GzipLevel = c.GzipLevel.Value - } - - if c.Format.WasSet { - input.Format = c.Format.Value - } - - if c.FormatVersion.WasSet { - input.FormatVersion = c.FormatVersion.Value - } - - if c.ResponseCondition.WasSet { - input.ResponseCondition = c.ResponseCondition.Value - } - - if c.MessageType.WasSet { - input.MessageType = c.MessageType.Value - } - - if c.TimestampFormat.WasSet { - input.TimestampFormat = c.TimestampFormat.Value - } - - if c.Placement.WasSet { - input.Placement = c.Placement.Value - } - - if c.PublicKey.WasSet { - input.PublicKey = c.PublicKey.Value - } - - if c.FileMaxBytes.WasSet { - input.FileMaxBytes = c.FileMaxBytes.Value - } - - if c.CompressionCodec.WasSet { - input.CompressionCodec = c.CompressionCodec.Value - } - - return &input, nil -} - -// Exec invokes the application logic for the command. -func (c *CreateCommand) Exec(in io.Reader, out io.Writer) error { - input, err := c.createInput() - if err != nil { - return err - } - - d, err := c.Globals.Client.CreateBlobStorage(input) - if err != nil { - return err - } - - text.Success(out, "Created Azure Blob Storage logging endpoint %s (service %s version %d)", d.Name, d.ServiceID, d.ServiceVersion) - return nil -} diff --git a/pkg/logging/azureblob/delete.go b/pkg/logging/azureblob/delete.go deleted file mode 100644 index c433ff067..000000000 --- a/pkg/logging/azureblob/delete.go +++ /dev/null @@ -1,51 +0,0 @@ -package azureblob - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// DeleteCommand calls the Fastly API to delete an Azure Blob Storage logging endpoint. -type DeleteCommand struct { - common.Base - manifest manifest.Data - Input fastly.DeleteBlobStorageInput -} - -// NewDeleteCommand returns a usable command registered under the parent. -func NewDeleteCommand(parent common.Registerer, globals *config.Data) *DeleteCommand { - var c DeleteCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("delete", "Delete an Azure Blob Storage logging endpoint on a Fastly service version").Alias("remove") - - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Input.ServiceVersion) - c.CmdClause.Flag("name", "The name of the Azure Blob Storage logging object").Short('n').Required().StringVar(&c.Input.Name) - - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - - return &c -} - -// Exec invokes the application logic for the command. -func (c *DeleteCommand) Exec(in io.Reader, out io.Writer) error { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - c.Input.ServiceID = serviceID - - if err := c.Globals.Client.DeleteBlobStorage(&c.Input); err != nil { - return err - } - - text.Success(out, "Deleted Azure Blob Storage logging endpoint %s (service %s version %d)", c.Input.Name, c.Input.ServiceID, c.Input.ServiceVersion) - return nil -} diff --git a/pkg/logging/azureblob/describe.go b/pkg/logging/azureblob/describe.go deleted file mode 100644 index 0dcedae4f..000000000 --- a/pkg/logging/azureblob/describe.go +++ /dev/null @@ -1,67 +0,0 @@ -package azureblob - -import ( - "fmt" - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/go-fastly/v3/fastly" -) - -// DescribeCommand calls the Fastly API to describe an Azure Blob Storage logging endpoint. -type DescribeCommand struct { - common.Base - manifest manifest.Data - Input fastly.GetBlobStorageInput -} - -// NewDescribeCommand returns a usable command registered under the parent. -func NewDescribeCommand(parent common.Registerer, globals *config.Data) *DescribeCommand { - var c DescribeCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("describe", "Show detailed information about an Azure Blob Storage logging endpoint on a Fastly service version").Alias("get") - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Input.ServiceVersion) - c.CmdClause.Flag("name", "The name of the Azure Blob Storage logging object").Short('n').Required().StringVar(&c.Input.Name) - return &c -} - -// Exec invokes the application logic for the command. -func (c *DescribeCommand) Exec(in io.Reader, out io.Writer) error { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - c.Input.ServiceID = serviceID - - azureblob, err := c.Globals.Client.GetBlobStorage(&c.Input) - if err != nil { - return err - } - - fmt.Fprintf(out, "Service ID: %s\n", azureblob.ServiceID) - fmt.Fprintf(out, "Version: %d\n", azureblob.ServiceVersion) - fmt.Fprintf(out, "Name: %s\n", azureblob.Name) - fmt.Fprintf(out, "Container: %s\n", azureblob.Container) - fmt.Fprintf(out, "Account name: %s\n", azureblob.AccountName) - fmt.Fprintf(out, "SAS token: %s\n", azureblob.SASToken) - fmt.Fprintf(out, "Path: %s\n", azureblob.Path) - fmt.Fprintf(out, "Period: %d\n", azureblob.Period) - fmt.Fprintf(out, "GZip level: %d\n", azureblob.GzipLevel) - fmt.Fprintf(out, "Format: %s\n", azureblob.Format) - fmt.Fprintf(out, "Format version: %d\n", azureblob.FormatVersion) - fmt.Fprintf(out, "Response condition: %s\n", azureblob.ResponseCondition) - fmt.Fprintf(out, "Message type: %s\n", azureblob.MessageType) - fmt.Fprintf(out, "Timestamp format: %s\n", azureblob.TimestampFormat) - fmt.Fprintf(out, "Placement: %s\n", azureblob.Placement) - fmt.Fprintf(out, "Public key: %s\n", azureblob.PublicKey) - fmt.Fprintf(out, "File max bytes: %d\n", azureblob.FileMaxBytes) - fmt.Fprintf(out, "Compression codec: %s\n", azureblob.CompressionCodec) - - return nil -} diff --git a/pkg/logging/azureblob/list.go b/pkg/logging/azureblob/list.go deleted file mode 100644 index baf7e88d5..000000000 --- a/pkg/logging/azureblob/list.go +++ /dev/null @@ -1,83 +0,0 @@ -package azureblob - -import ( - "fmt" - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// ListCommand calls the Fastly API to list Azure Blob Storage logging endpoints. -type ListCommand struct { - common.Base - manifest manifest.Data - Input fastly.ListBlobStoragesInput -} - -// NewListCommand returns a usable command registered under the parent. -func NewListCommand(parent common.Registerer, globals *config.Data) *ListCommand { - var c ListCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("list", "List Azure Blob Storage logging endpoints on a Fastly service version") - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Input.ServiceVersion) - return &c -} - -// Exec invokes the application logic for the command. -func (c *ListCommand) Exec(in io.Reader, out io.Writer) error { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - c.Input.ServiceID = serviceID - - azureblobs, err := c.Globals.Client.ListBlobStorages(&c.Input) - if err != nil { - return err - } - - if !c.Globals.Verbose() { - tw := text.NewTable(out) - tw.AddHeader("SERVICE", "VERSION", "NAME") - for _, azureblob := range azureblobs { - tw.AddLine(azureblob.ServiceID, azureblob.ServiceVersion, azureblob.Name) - } - tw.Print() - return nil - } - - fmt.Fprintf(out, "Service ID: %s\n", c.Input.ServiceID) - fmt.Fprintf(out, "Version: %d\n", c.Input.ServiceVersion) - for i, azureblob := range azureblobs { - fmt.Fprintf(out, "\tBlobStorage %d/%d\n", i+1, len(azureblobs)) - fmt.Fprintf(out, "\t\tService ID: %s\n", azureblob.ServiceID) - fmt.Fprintf(out, "\t\tVersion: %d\n", azureblob.ServiceVersion) - fmt.Fprintf(out, "\t\tName: %s\n", azureblob.Name) - fmt.Fprintf(out, "\t\tContainer: %s\n", azureblob.Container) - fmt.Fprintf(out, "\t\tAccount name: %s\n", azureblob.AccountName) - fmt.Fprintf(out, "\t\tSAS token: %s\n", azureblob.SASToken) - fmt.Fprintf(out, "\t\tPath: %s\n", azureblob.Path) - fmt.Fprintf(out, "\t\tPeriod: %d\n", azureblob.Period) - fmt.Fprintf(out, "\t\tGZip level: %d\n", azureblob.GzipLevel) - fmt.Fprintf(out, "\t\tFormat: %s\n", azureblob.Format) - fmt.Fprintf(out, "\t\tFormat version: %d\n", azureblob.FormatVersion) - fmt.Fprintf(out, "\t\tResponse condition: %s\n", azureblob.ResponseCondition) - fmt.Fprintf(out, "\t\tMessage type: %s\n", azureblob.MessageType) - fmt.Fprintf(out, "\t\tTimestamp format: %s\n", azureblob.TimestampFormat) - fmt.Fprintf(out, "\t\tPlacement: %s\n", azureblob.Placement) - fmt.Fprintf(out, "\t\tPublic key: %s\n", azureblob.PublicKey) - fmt.Fprintf(out, "\t\tFile max bytes: %d\n", azureblob.FileMaxBytes) - fmt.Fprintf(out, "\t\tCompression codec: %s\n", azureblob.CompressionCodec) - } - fmt.Fprintln(out) - - return nil -} diff --git a/pkg/logging/azureblob/root.go b/pkg/logging/azureblob/root.go deleted file mode 100644 index 6379dc7d1..000000000 --- a/pkg/logging/azureblob/root.go +++ /dev/null @@ -1,28 +0,0 @@ -package azureblob - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/config" -) - -// RootCommand is the parent command for all subcommands in this package. -// It should be installed under the primary root command. -type RootCommand struct { - common.Base - // no flags -} - -// NewRootCommand returns a new command registered in the parent. -func NewRootCommand(parent common.Registerer, globals *config.Data) *RootCommand { - var c RootCommand - c.Globals = globals - c.CmdClause = parent.Command("azureblob", "Manipulate Fastly service version Azure Blob Storage logging endpoints") - return &c -} - -// Exec implements the command interface. -func (c *RootCommand) Exec(in io.Reader, out io.Writer) error { - panic("unreachable") -} diff --git a/pkg/logging/azureblob/update.go b/pkg/logging/azureblob/update.go deleted file mode 100644 index 66e1f6dfe..000000000 --- a/pkg/logging/azureblob/update.go +++ /dev/null @@ -1,170 +0,0 @@ -package azureblob - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// UpdateCommand calls the Fastly API to update an Azure Blob Storage logging endpoint. -type UpdateCommand struct { - common.Base - manifest manifest.Data - - //required - EndpointName string - Version int - - // optional - NewName common.OptionalString - AccountName common.OptionalString - Container common.OptionalString - SASToken common.OptionalString - Path common.OptionalString - Period common.OptionalUint - GzipLevel common.OptionalUint - MessageType common.OptionalString - Format common.OptionalString - FormatVersion common.OptionalUint - ResponseCondition common.OptionalString - TimestampFormat common.OptionalString - Placement common.OptionalString - PublicKey common.OptionalString - FileMaxBytes common.OptionalUint - CompressionCodec common.OptionalString -} - -// NewUpdateCommand returns a usable command registered under the parent. -func NewUpdateCommand(parent common.Registerer, globals *config.Data) *UpdateCommand { - var c UpdateCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - - c.CmdClause = parent.Command("update", "Update an Azure Blob Storage logging endpoint on a Fastly service version") - - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Version) - c.CmdClause.Flag("name", "The name of the Azure Blob Storage logging object").Short('n').Required().StringVar(&c.EndpointName) - - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("new-name", "New name of the Azure Blob Storage logging object").Action(c.NewName.Set).StringVar(&c.NewName.Value) - c.CmdClause.Flag("container", "The name of the Azure Blob Storage container in which to store logs").Action(c.Container.Set).StringVar(&c.Container.Value) - c.CmdClause.Flag("account-name", "The unique Azure Blob Storage namespace in which your data objects are stored").Action(c.AccountName.Set).StringVar(&c.AccountName.Value) - c.CmdClause.Flag("sas-token", "The Azure shared access signature providing write access to the blob service objects. Be sure to update your token before it expires or the logging functionality will not work").Action(c.SASToken.Set).StringVar(&c.SASToken.Value) - c.CmdClause.Flag("path", "The path to upload logs to").Action(c.Path.Set).StringVar(&c.Path.Value) - c.CmdClause.Flag("period", "How frequently log files are finalized so they can be available for reading (in seconds, default 3600)").Action(c.Period.Set).UintVar(&c.Period.Value) - c.CmdClause.Flag("gzip-level", "What level of GZIP encoding to have when dumping logs (default 0, no compression)").Action(c.GzipLevel.Set).UintVar(&c.GzipLevel.Value) - c.CmdClause.Flag("format", "Apache style log formatting").Action(c.Format.Set).StringVar(&c.Format.Value) - c.CmdClause.Flag("message-type", "How the message should be formatted. One of: classic (default), loggly, logplex or blank").Action(c.MessageType.Set).StringVar(&c.MessageType.Value) - c.CmdClause.Flag("format-version", "The version of the custom logging format used for the configured endpoint. Can be either 2 (default) or 1").Action(c.FormatVersion.Set).UintVar(&c.FormatVersion.Value) - c.CmdClause.Flag("response-condition", "The name of an existing condition in the configured endpoint, or leave blank to always execute").Action(c.ResponseCondition.Set).StringVar(&c.ResponseCondition.Value) - c.CmdClause.Flag("timestamp-format", `strftime specified timestamp formatting (default "%Y-%m-%dT%H:%M:%S.000")`).Action(c.TimestampFormat.Set).StringVar(&c.TimestampFormat.Value) - c.CmdClause.Flag("placement", "Where in the generated VCL the logging call should be placed, overriding any format_version default. Can be none or waf_debug").Action(c.Placement.Set).StringVar(&c.Placement.Value) - c.CmdClause.Flag("public-key", "A PGP public key that Fastly will use to encrypt your log files before writing them to disk").Action(c.PublicKey.Set).StringVar(&c.PublicKey.Value) - c.CmdClause.Flag("file-max-bytes", "The maximum size of a log file in bytes").Action(c.FileMaxBytes.Set).UintVar(&c.FileMaxBytes.Value) - c.CmdClause.Flag("compression-codec", `The codec used for compression of your logs. Valid values are zstd, snappy, and gzip. If the specified codec is "gzip", gzip_level will default to 3. To specify a different level, leave compression_codec blank and explicitly set the level using gzip_level. Specifying both compression_codec and gzip_level in the same API request will result in an error.`).Action(c.CompressionCodec.Set).StringVar(&c.CompressionCodec.Value) - - return &c -} - -// createInput transforms values parsed from CLI flags into an object to be used by the API client library. -func (c *UpdateCommand) createInput() (*fastly.UpdateBlobStorageInput, error) { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return nil, errors.ErrNoServiceID - } - - input := fastly.UpdateBlobStorageInput{ - ServiceID: serviceID, - ServiceVersion: c.Version, - Name: c.EndpointName, - } - - // Set new values if set by user. - if c.NewName.WasSet { - input.NewName = fastly.String(c.NewName.Value) - } - - if c.Path.WasSet { - input.Path = fastly.String(c.Path.Value) - } - - if c.AccountName.WasSet { - input.AccountName = fastly.String(c.AccountName.Value) - } - - if c.Container.WasSet { - input.Container = fastly.String(c.Container.Value) - } - - if c.SASToken.WasSet { - input.SASToken = fastly.String(c.SASToken.Value) - } - - if c.Period.WasSet { - input.Period = fastly.Uint(c.Period.Value) - } - - if c.GzipLevel.WasSet { - input.GzipLevel = fastly.Uint(c.GzipLevel.Value) - } - - if c.Format.WasSet { - input.Format = fastly.String(c.Format.Value) - } - - if c.FormatVersion.WasSet { - input.FormatVersion = fastly.Uint(c.FormatVersion.Value) - } - - if c.ResponseCondition.WasSet { - input.ResponseCondition = fastly.String(c.ResponseCondition.Value) - } - - if c.MessageType.WasSet { - input.MessageType = fastly.String(c.MessageType.Value) - } - - if c.TimestampFormat.WasSet { - input.TimestampFormat = fastly.String(c.TimestampFormat.Value) - } - - if c.Placement.WasSet { - input.Placement = fastly.String(c.Placement.Value) - } - - if c.PublicKey.WasSet { - input.PublicKey = fastly.String(c.PublicKey.Value) - } - - if c.FileMaxBytes.WasSet { - input.FileMaxBytes = fastly.Uint(c.FileMaxBytes.Value) - } - - if c.CompressionCodec.WasSet { - input.CompressionCodec = fastly.String(c.CompressionCodec.Value) - } - - return &input, nil -} - -// Exec invokes the application logic for the command. -func (c *UpdateCommand) Exec(in io.Reader, out io.Writer) error { - input, err := c.createInput() - if err != nil { - return err - } - - azureblob, err := c.Globals.Client.UpdateBlobStorage(input) - if err != nil { - return err - } - - text.Success(out, "Updated Azure Blob Storage logging endpoint %s (service %s version %d)", azureblob.Name, azureblob.ServiceID, azureblob.ServiceVersion) - return nil -} diff --git a/pkg/logging/bigquery/bigquery_integration_test.go b/pkg/logging/bigquery/bigquery_integration_test.go deleted file mode 100644 index 129953fe1..000000000 --- a/pkg/logging/bigquery/bigquery_integration_test.go +++ /dev/null @@ -1,400 +0,0 @@ -package bigquery_test - -import ( - "bytes" - "errors" - "io" - "net/http" - "strings" - "testing" - - "github.com/fastly/cli/pkg/app" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/mock" - "github.com/fastly/cli/pkg/testutil" - "github.com/fastly/cli/pkg/update" - "github.com/fastly/go-fastly/v3/fastly" -) - -func TestBigQueryCreate(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "bigquery", "create", "--service-id", "123", "--version", "1", "--name", "log", "--project-id", "project123", "--dataset", "logs", "--table", "logs", "--user", "user@domain.com"}, - api: mock.API{CreateBigQueryFn: createBigQueryOK}, - wantError: "error parsing arguments: required flag --secret-key not provided", - }, - { - args: []string{"logging", "bigquery", "create", "--service-id", "123", "--version", "1", "--name", "log", "--project-id", "project123", "--dataset", "logs", "--table", "logs", "--user", "user@domain.com", "--secret-key", `"-----BEGIN RSA PRIVATE KEY-----MIIEogIBAAKCA"`}, - api: mock.API{CreateBigQueryFn: createBigQueryOK}, - wantOutput: "Created BigQuery logging endpoint log (service 123 version 1)", - }, - { - args: []string{"logging", "bigquery", "create", "--service-id", "123", "--version", "1", "--name", "log", "--project-id", "project123", "--dataset", "logs", "--table", "logs", "--user", "user@domain.com", "--secret-key", `"-----BEGIN RSA PRIVATE KEY-----MIIEogIBAAKCA"`}, - api: mock.API{CreateBigQueryFn: createBigQueryError}, - wantError: errTest.Error(), - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertStringContains(t, out.String(), testcase.wantOutput) - }) - } -} - -func TestBigQueryList(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "bigquery", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListBigQueriesFn: listBigQueriesOK}, - wantOutput: listBigQueriesShortOutput, - }, - { - args: []string{"logging", "bigquery", "list", "--service-id", "123", "--version", "1", "--verbose"}, - api: mock.API{ListBigQueriesFn: listBigQueriesOK}, - wantOutput: listBigQueriesVerboseOutput, - }, - { - args: []string{"logging", "bigquery", "list", "--service-id", "123", "--version", "1", "-v"}, - api: mock.API{ListBigQueriesFn: listBigQueriesOK}, - wantOutput: listBigQueriesVerboseOutput, - }, - { - args: []string{"logging", "bigquery", "--verbose", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListBigQueriesFn: listBigQueriesOK}, - wantOutput: listBigQueriesVerboseOutput, - }, - { - args: []string{"logging", "-v", "bigquery", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListBigQueriesFn: listBigQueriesOK}, - wantOutput: listBigQueriesVerboseOutput, - }, - { - args: []string{"logging", "bigquery", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListBigQueriesFn: listBigQueriesError}, - wantError: errTest.Error(), - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertString(t, testcase.wantOutput, out.String()) - }) - } -} - -func TestBigQueryDescribe(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "bigquery", "describe", "--service-id", "123", "--version", "1"}, - api: mock.API{GetBigQueryFn: getBigQueryOK}, - wantError: "error parsing arguments: required flag --name not provided", - }, - { - args: []string{"logging", "bigquery", "describe", "--service-id", "123", "--version", "1", "--name", "logs"}, - api: mock.API{GetBigQueryFn: getBigQueryError}, - wantError: errTest.Error(), - }, - { - args: []string{"logging", "bigquery", "describe", "--service-id", "123", "--version", "1", "--name", "logs"}, - api: mock.API{GetBigQueryFn: getBigQueryOK}, - wantOutput: describeBigQueryOutput, - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertString(t, testcase.wantOutput, out.String()) - }) - } -} - -func TestBigQueryUpdate(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "bigquery", "update", "--service-id", "123", "--version", "1", "--new-name", "log", "--project-id", "project123", "--dataset", "logs", "--table", "logs", "--user", "user@domain.com", "--secret-key", `"-----BEGIN RSA PRIVATE KEY-----MIIEogIBAAKCA"`}, - api: mock.API{UpdateBigQueryFn: updateBigQueryOK}, - wantError: "error parsing arguments: required flag --name not provided", - }, - { - args: []string{"logging", "bigquery", "update", "--service-id", "123", "--version", "1", "--name", "logs", "--new-name", "log"}, - api: mock.API{UpdateBigQueryFn: updateBigQueryError}, - wantError: errTest.Error(), - }, - { - args: []string{"logging", "bigquery", "update", "--service-id", "123", "--version", "1", "--name", "logs", "--new-name", "log"}, - api: mock.API{UpdateBigQueryFn: updateBigQueryOK}, - wantOutput: "Updated BigQuery logging endpoint log (service 123 version 1)", - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertStringContains(t, out.String(), testcase.wantOutput) - }) - } -} - -func TestBigQueryDelete(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "bigquery", "delete", "--service-id", "123", "--version", "1"}, - api: mock.API{DeleteBigQueryFn: deleteBigQueryOK}, - wantError: "error parsing arguments: required flag --name not provided", - }, - { - args: []string{"logging", "bigquery", "delete", "--service-id", "123", "--version", "1", "--name", "logs"}, - api: mock.API{DeleteBigQueryFn: deleteBigQueryError}, - wantError: errTest.Error(), - }, - { - args: []string{"logging", "bigquery", "delete", "--service-id", "123", "--version", "1", "--name", "logs"}, - api: mock.API{DeleteBigQueryFn: deleteBigQueryOK}, - wantOutput: "Deleted BigQuery logging endpoint logs (service 123 version 1)", - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertStringContains(t, out.String(), testcase.wantOutput) - }) - } -} - -var errTest = errors.New("fixture error") - -func createBigQueryOK(i *fastly.CreateBigQueryInput) (*fastly.BigQuery, error) { - return &fastly.BigQuery{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: i.Name, - }, nil -} - -func createBigQueryError(i *fastly.CreateBigQueryInput) (*fastly.BigQuery, error) { - return nil, errTest -} - -func listBigQueriesOK(i *fastly.ListBigQueriesInput) ([]*fastly.BigQuery, error) { - return []*fastly.BigQuery{ - { - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "logs", - ProjectID: "my-project", - Dataset: "raw-logs", - Table: "logs", - User: "service-account@domain.com", - SecretKey: "-----BEGIN RSA PRIVATE KEY-----MIIEogIBAAKCA", - Format: `%h %l %u %t "%r" %>s %b`, - Template: "%Y%m%d", - Placement: "none", - ResponseCondition: "Prevent default logging", - }, - { - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "analytics", - ProjectID: "my-project", - Dataset: "analytics", - Table: "logs", - User: "service-account@domain.com", - SecretKey: "-----BEGIN RSA PRIVATE KEY-----MIIEogIBAAKCA", - Format: `%h %l %u %t "%r" %>s %b`, - Template: "%Y%m%d", - Placement: "none", - ResponseCondition: "Prevent default logging", - }, - }, nil -} - -func listBigQueriesError(i *fastly.ListBigQueriesInput) ([]*fastly.BigQuery, error) { - return nil, errTest -} - -var listBigQueriesShortOutput = strings.TrimSpace(` -SERVICE VERSION NAME -123 1 logs -123 1 analytics -`) + "\n" - -var listBigQueriesVerboseOutput = strings.TrimSpace(` -Fastly API token not provided -Fastly API endpoint: https://api.fastly.com -Service ID: 123 -Version: 1 - BigQuery 1/2 - Service ID: 123 - Version: 1 - Name: logs - Format: %h %l %u %t "%r" %>s %b - User: service-account@domain.com - Project ID: my-project - Dataset: raw-logs - Table: logs - Template suffix: %Y%m%d - Secret key: -----BEGIN RSA PRIVATE KEY-----MIIEogIBAAKCA - Response condition: Prevent default logging - Placement: none - Format version: 0 - BigQuery 2/2 - Service ID: 123 - Version: 1 - Name: analytics - Format: %h %l %u %t "%r" %>s %b - User: service-account@domain.com - Project ID: my-project - Dataset: analytics - Table: logs - Template suffix: %Y%m%d - Secret key: -----BEGIN RSA PRIVATE KEY-----MIIEogIBAAKCA - Response condition: Prevent default logging - Placement: none - Format version: 0 -`) + "\n\n" - -func getBigQueryOK(i *fastly.GetBigQueryInput) (*fastly.BigQuery, error) { - return &fastly.BigQuery{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "logs", - ProjectID: "my-project", - Dataset: "raw-logs", - Table: "logs", - User: "service-account@domain.com", - SecretKey: "-----BEGIN RSA PRIVATE KEY-----MIIEogIBAAKCA", - Format: `%h %l %u %t "%r" %>s %b`, - Template: "%Y%m%d", - Placement: "none", - ResponseCondition: "Prevent default logging", - }, nil -} - -func getBigQueryError(i *fastly.GetBigQueryInput) (*fastly.BigQuery, error) { - return nil, errTest -} - -var describeBigQueryOutput = strings.TrimSpace(` -Service ID: 123 -Version: 1 -Name: logs -Format: %h %l %u %t "%r" %>s %b -User: service-account@domain.com -Project ID: my-project -Dataset: raw-logs -Table: logs -Template suffix: %Y%m%d -Secret key: -----BEGIN RSA PRIVATE KEY-----MIIEogIBAAKCA -Response condition: Prevent default logging -Placement: none -Format version: 0 -`) + "\n" - -func updateBigQueryOK(i *fastly.UpdateBigQueryInput) (*fastly.BigQuery, error) { - return &fastly.BigQuery{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "log", - ProjectID: "my-project", - Dataset: "raw-logs", - Table: "logs", - User: "service-account@domain.com", - SecretKey: "-----BEGIN RSA PRIVATE KEY-----MIIEogIBAAKCA", - Format: `%h %l %u %t "%r" %>s %b`, - Template: "%Y%m%d", - Placement: "none", - ResponseCondition: "Prevent default logging", - }, nil -} - -func updateBigQueryError(i *fastly.UpdateBigQueryInput) (*fastly.BigQuery, error) { - return nil, errTest -} - -func deleteBigQueryOK(i *fastly.DeleteBigQueryInput) error { - return nil -} - -func deleteBigQueryError(i *fastly.DeleteBigQueryInput) error { - return errTest -} diff --git a/pkg/logging/bigquery/bigquery_test.go b/pkg/logging/bigquery/bigquery_test.go deleted file mode 100644 index c2f41b573..000000000 --- a/pkg/logging/bigquery/bigquery_test.go +++ /dev/null @@ -1,218 +0,0 @@ -package bigquery - -import ( - "testing" - "time" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/mock" - "github.com/fastly/cli/pkg/testutil" - "github.com/fastly/go-fastly/v3/fastly" -) - -func TestCreateBigQueryInput(t *testing.T) { - for _, testcase := range []struct { - name string - cmd *CreateCommand - want *fastly.CreateBigQueryInput - wantError string - }{ - { - name: "required values set flag serviceID", - cmd: createCommandRequired(), - want: &fastly.CreateBigQueryInput{ - ServiceID: "123", - ServiceVersion: 2, - Name: "log", - ProjectID: "123", - Dataset: "dataset", - Table: "table", - User: "user", - SecretKey: "-----BEGIN PRIVATE KEY-----foo", - }, - }, - { - name: "all values set flag serviceID", - cmd: createCommandAll(), - want: &fastly.CreateBigQueryInput{ - ServiceID: "123", - ServiceVersion: 2, - Name: "log", - ProjectID: "123", - Dataset: "dataset", - Table: "table", - Template: "template", - User: "user", - SecretKey: "-----BEGIN PRIVATE KEY-----foo", - Format: `%h %l %u %t "%r" %>s %b`, - ResponseCondition: "Prevent default logging", - Placement: "none", - FormatVersion: 2, - }, - }, - { - name: "error missing serviceID", - cmd: createCommandMissingServiceID(), - want: nil, - wantError: errors.ErrNoServiceID.Error(), - }, - } { - t.Run(testcase.name, func(t *testing.T) { - have, err := testcase.cmd.createInput() - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertEqual(t, testcase.want, have) - }) - } -} - -func TestUpdateBigQueryInput(t *testing.T) { - for _, testcase := range []struct { - name string - cmd *UpdateCommand - api mock.API - want *fastly.UpdateBigQueryInput - wantError string - }{ - { - name: "no updates", - cmd: updateCommandNoUpdates(), - api: mock.API{GetBigQueryFn: getBigQueryOK}, - want: &fastly.UpdateBigQueryInput{ - ServiceID: "123", - ServiceVersion: 2, - Name: "log", - }, - }, - { - name: "all values set flag serviceID", - cmd: updateCommandAll(), - api: mock.API{GetBigQueryFn: getBigQueryOK}, - want: &fastly.UpdateBigQueryInput{ - ServiceID: "123", - ServiceVersion: 2, - Name: "log", - NewName: fastly.String("new1"), - ProjectID: fastly.String("new2"), - Dataset: fastly.String("new3"), - Table: fastly.String("new4"), - User: fastly.String("new5"), - SecretKey: fastly.String("new6"), - Template: fastly.String("new7"), - ResponseCondition: fastly.String("new8"), - Placement: fastly.String("new9"), - Format: fastly.String("new10"), - FormatVersion: fastly.Uint(3), - }, - }, - { - name: "error missing serviceID", - cmd: updateCommandMissingServiceID(), - want: nil, - wantError: errors.ErrNoServiceID.Error(), - }, - } { - t.Run(testcase.name, func(t *testing.T) { - testcase.cmd.Base.Globals.Client = testcase.api - - have, err := testcase.cmd.createInput() - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertEqual(t, testcase.want, have) - }) - } -} - -func createCommandRequired() *CreateCommand { - return &CreateCommand{ - manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, - EndpointName: "log", - Version: 2, - ProjectID: "123", - Dataset: "dataset", - Table: "table", - User: "user", - SecretKey: "-----BEGIN PRIVATE KEY-----foo", - } -} - -func createCommandAll() *CreateCommand { - return &CreateCommand{ - manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, - EndpointName: "log", - Version: 2, - ProjectID: "123", - Dataset: "dataset", - Table: "table", - User: "user", - SecretKey: "-----BEGIN PRIVATE KEY-----foo", - Template: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "template"}, - ResponseCondition: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "Prevent default logging"}, - Placement: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "none"}, - Format: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: `%h %l %u %t "%r" %>s %b`}, - FormatVersion: common.OptionalUint{Optional: common.Optional{WasSet: true}, Value: 2}, - } -} - -func createCommandMissingServiceID() *CreateCommand { - res := createCommandAll() - res.manifest = manifest.Data{} - return res -} - -func updateCommandNoUpdates() *UpdateCommand { - return &UpdateCommand{ - Base: common.Base{Globals: &config.Data{Client: nil}}, - manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, - EndpointName: "log", - Version: 2, - } -} - -func updateCommandAll() *UpdateCommand { - return &UpdateCommand{ - Base: common.Base{Globals: &config.Data{Client: nil}}, - manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, - EndpointName: "log", - Version: 2, - NewName: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new1"}, - ProjectID: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new2"}, - Dataset: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new3"}, - Table: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new4"}, - User: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new5"}, - SecretKey: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new6"}, - Template: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new7"}, - ResponseCondition: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new8"}, - Placement: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new9"}, - Format: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new10"}, - FormatVersion: common.OptionalUint{Optional: common.Optional{WasSet: true}, Value: 3}, - } -} - -func updateCommandMissingServiceID() *UpdateCommand { - res := updateCommandAll() - res.manifest = manifest.Data{} - return res -} - -func getBigQueryOK(i *fastly.GetBigQueryInput) (*fastly.BigQuery, error) { - return &fastly.BigQuery{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "logs", - Format: `%h %l %u %t "%r" %>s %b`, - User: "user", - ProjectID: "123", - Dataset: "dataset", - Table: "table", - Template: "template", - SecretKey: "-----BEGIN PRIVATE KEY-----foo", - ResponseCondition: "Prevent default logging", - Placement: "none", - FormatVersion: 2, - CreatedAt: &time.Time{}, - UpdatedAt: &time.Time{}, - DeletedAt: &time.Time{}, - }, nil -} diff --git a/pkg/logging/bigquery/create.go b/pkg/logging/bigquery/create.go deleted file mode 100644 index fe0f98d32..000000000 --- a/pkg/logging/bigquery/create.go +++ /dev/null @@ -1,117 +0,0 @@ -package bigquery - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// CreateCommand calls the Fastly API to create a BigQuery logging endpoint. -type CreateCommand struct { - common.Base - manifest manifest.Data - - // required - EndpointName string // Can't shadow common.Base method Name(). - Version int - ProjectID string - Dataset string - Table string - User string - SecretKey string - - // optional - Template common.OptionalString - Placement common.OptionalString - ResponseCondition common.OptionalString - Format common.OptionalString - FormatVersion common.OptionalUint -} - -// NewCreateCommand returns a usable command registered under the parent. -func NewCreateCommand(parent common.Registerer, globals *config.Data) *CreateCommand { - var c CreateCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("create", "Create a BigQuery logging endpoint on a Fastly service version").Alias("add") - - c.CmdClause.Flag("name", "The name of the BigQuery logging object. Used as a primary key for API access").Short('n').Required().StringVar(&c.EndpointName) - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Version) - c.CmdClause.Flag("project-id", "Your Google Cloud Platform project ID").Required().StringVar(&c.ProjectID) - c.CmdClause.Flag("dataset", "Your BigQuery dataset").Required().StringVar(&c.Dataset) - c.CmdClause.Flag("table", "Your BigQuery table").Required().StringVar(&c.Table) - c.CmdClause.Flag("user", "Your Google Cloud Platform service account email address. The client_email field in your service account authentication JSON.").Required().StringVar(&c.User) - c.CmdClause.Flag("secret-key", "Your Google Cloud Platform account secret key. The private_key field in your service account authentication JSON.").Required().StringVar(&c.SecretKey) - - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("template-suffix", "BigQuery table name suffix template").Action(c.Template.Set).StringVar(&c.Template.Value) - c.CmdClause.Flag("format", "Apache style log formatting. Must produce JSON that matches the schema of your BigQuery table").Action(c.Format.Set).StringVar(&c.Format.Value) - c.CmdClause.Flag("format-version", "The version of the custom logging format used for the configured endpoint. Can be either 2 (the default, version 2 log format) or 1 (the version 1 log format). The logging call gets placed by default in vcl_log if format_version is set to 2 and in vcl_deliver if format_version is set to 1").Action(c.FormatVersion.Set).UintVar(&c.FormatVersion.Value) - c.CmdClause.Flag("placement", "Where in the generated VCL the logging call should be placed, overriding any format_version default. Can be none or waf_debug. This field is not required and has no default value").Action(c.Placement.Set).StringVar(&c.Placement.Value) - c.CmdClause.Flag("response-condition", "The name of an existing condition in the configured endpoint, or leave blank to always execute").Action(c.ResponseCondition.Set).StringVar(&c.ResponseCondition.Value) - - return &c -} - -// createInput transforms values parsed from CLI flags into an object to be used by the API client library. -func (c *CreateCommand) createInput() (*fastly.CreateBigQueryInput, error) { - var input fastly.CreateBigQueryInput - - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return nil, errors.ErrNoServiceID - } - - input.ServiceID = serviceID - input.ServiceVersion = c.Version - input.Name = c.EndpointName - input.ProjectID = c.ProjectID - input.Dataset = c.Dataset - input.User = c.User - input.Table = c.Table - input.SecretKey = c.SecretKey - - if c.Template.WasSet { - input.Template = c.Template.Value - } - - if c.Format.WasSet { - input.Format = c.Format.Value - } - - if c.FormatVersion.WasSet { - input.FormatVersion = c.FormatVersion.Value - } - - if c.Placement.WasSet { - input.Placement = c.Placement.Value - } - - if c.ResponseCondition.WasSet { - input.ResponseCondition = c.ResponseCondition.Value - } - - return &input, nil -} - -// Exec invokes the application logic for the command. -func (c *CreateCommand) Exec(in io.Reader, out io.Writer) error { - input, err := c.createInput() - if err != nil { - return err - } - - d, err := c.Globals.Client.CreateBigQuery(input) - if err != nil { - return err - } - - text.Success(out, "Created BigQuery logging endpoint %s (service %s version %d)", d.Name, d.ServiceID, d.ServiceVersion) - return nil -} diff --git a/pkg/logging/bigquery/delete.go b/pkg/logging/bigquery/delete.go deleted file mode 100644 index 6b213fa73..000000000 --- a/pkg/logging/bigquery/delete.go +++ /dev/null @@ -1,51 +0,0 @@ -package bigquery - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// DeleteCommand calls the Fastly API to delete a BigQuery logging endpoint. -type DeleteCommand struct { - common.Base - manifest manifest.Data - Input fastly.DeleteBigQueryInput -} - -// NewDeleteCommand returns a usable command registered under the parent. -func NewDeleteCommand(parent common.Registerer, globals *config.Data) *DeleteCommand { - var c DeleteCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("delete", "Delete a BigQuery logging endpoint on a Fastly service version").Alias("remove") - - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Input.ServiceVersion) - c.CmdClause.Flag("name", "The name of the BigQuery logging object").Short('n').Required().StringVar(&c.Input.Name) - - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - - return &c -} - -// Exec invokes the application logic for the command. -func (c *DeleteCommand) Exec(in io.Reader, out io.Writer) error { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - c.Input.ServiceID = serviceID - - if err := c.Globals.Client.DeleteBigQuery(&c.Input); err != nil { - return err - } - - text.Success(out, "Deleted BigQuery logging endpoint %s (service %s version %d)", c.Input.Name, c.Input.ServiceID, c.Input.ServiceVersion) - return nil -} diff --git a/pkg/logging/bigquery/describe.go b/pkg/logging/bigquery/describe.go deleted file mode 100644 index 6dda57713..000000000 --- a/pkg/logging/bigquery/describe.go +++ /dev/null @@ -1,62 +0,0 @@ -package bigquery - -import ( - "fmt" - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/go-fastly/v3/fastly" -) - -// DescribeCommand calls the Fastly API to describe a BigQuery logging endpoint. -type DescribeCommand struct { - common.Base - manifest manifest.Data - Input fastly.GetBigQueryInput -} - -// NewDescribeCommand returns a usable command registered under the parent. -func NewDescribeCommand(parent common.Registerer, globals *config.Data) *DescribeCommand { - var c DescribeCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("describe", "Show detailed information about a BigQuery logging endpoint on a Fastly service version").Alias("get") - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Input.ServiceVersion) - c.CmdClause.Flag("name", "The name of the BigQuery logging object").Short('n').Required().StringVar(&c.Input.Name) - return &c -} - -// Exec invokes the application logic for the command. -func (c *DescribeCommand) Exec(in io.Reader, out io.Writer) error { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - c.Input.ServiceID = serviceID - - bq, err := c.Globals.Client.GetBigQuery(&c.Input) - if err != nil { - return err - } - - fmt.Fprintf(out, "Service ID: %s\n", bq.ServiceID) - fmt.Fprintf(out, "Version: %d\n", bq.ServiceVersion) - fmt.Fprintf(out, "Name: %s\n", bq.Name) - fmt.Fprintf(out, "Format: %s\n", bq.Format) - fmt.Fprintf(out, "User: %s\n", bq.User) - fmt.Fprintf(out, "Project ID: %s\n", bq.ProjectID) - fmt.Fprintf(out, "Dataset: %s\n", bq.Dataset) - fmt.Fprintf(out, "Table: %s\n", bq.Table) - fmt.Fprintf(out, "Template suffix: %s\n", bq.Template) - fmt.Fprintf(out, "Secret key: %s\n", bq.SecretKey) - fmt.Fprintf(out, "Response condition: %s\n", bq.ResponseCondition) - fmt.Fprintf(out, "Placement: %s\n", bq.Placement) - fmt.Fprintf(out, "Format version: %d\n", bq.FormatVersion) - - return nil -} diff --git a/pkg/logging/bigquery/list.go b/pkg/logging/bigquery/list.go deleted file mode 100644 index e0c3b1cc9..000000000 --- a/pkg/logging/bigquery/list.go +++ /dev/null @@ -1,78 +0,0 @@ -package bigquery - -import ( - "fmt" - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// ListCommand calls the Fastly API to list BigQuery logging endpoints. -type ListCommand struct { - common.Base - manifest manifest.Data - Input fastly.ListBigQueriesInput -} - -// NewListCommand returns a usable command registered under the parent. -func NewListCommand(parent common.Registerer, globals *config.Data) *ListCommand { - var c ListCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("list", "List BigQuery endpoints on a Fastly service version") - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Input.ServiceVersion) - return &c -} - -// Exec invokes the application logic for the command. -func (c *ListCommand) Exec(in io.Reader, out io.Writer) error { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - c.Input.ServiceID = serviceID - - bqs, err := c.Globals.Client.ListBigQueries(&c.Input) - if err != nil { - return err - } - - if !c.Globals.Verbose() { - tw := text.NewTable(out) - tw.AddHeader("SERVICE", "VERSION", "NAME") - for _, bq := range bqs { - tw.AddLine(bq.ServiceID, bq.ServiceVersion, bq.Name) - } - tw.Print() - return nil - } - - fmt.Fprintf(out, "Service ID: %s\n", c.Input.ServiceID) - fmt.Fprintf(out, "Version: %d\n", c.Input.ServiceVersion) - for i, bq := range bqs { - fmt.Fprintf(out, "\tBigQuery %d/%d\n", i+1, len(bqs)) - fmt.Fprintf(out, "\t\tService ID: %s\n", bq.ServiceID) - fmt.Fprintf(out, "\t\tVersion: %d\n", bq.ServiceVersion) - fmt.Fprintf(out, "\t\tName: %s\n", bq.Name) - fmt.Fprintf(out, "\t\tFormat: %s\n", bq.Format) - fmt.Fprintf(out, "\t\tUser: %s\n", bq.User) - fmt.Fprintf(out, "\t\tProject ID: %s\n", bq.ProjectID) - fmt.Fprintf(out, "\t\tDataset: %s\n", bq.Dataset) - fmt.Fprintf(out, "\t\tTable: %s\n", bq.Table) - fmt.Fprintf(out, "\t\tTemplate suffix: %s\n", bq.Template) - fmt.Fprintf(out, "\t\tSecret key: %s\n", bq.SecretKey) - fmt.Fprintf(out, "\t\tResponse condition: %s\n", bq.ResponseCondition) - fmt.Fprintf(out, "\t\tPlacement: %s\n", bq.Placement) - fmt.Fprintf(out, "\t\tFormat version: %d\n", bq.FormatVersion) - } - fmt.Fprintln(out) - - return nil -} diff --git a/pkg/logging/bigquery/root.go b/pkg/logging/bigquery/root.go deleted file mode 100644 index 492c031be..000000000 --- a/pkg/logging/bigquery/root.go +++ /dev/null @@ -1,28 +0,0 @@ -package bigquery - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/config" -) - -// RootCommand is the parent command for all subcommands in this package. -// It should be installed under the primary root command. -type RootCommand struct { - common.Base - // no flags -} - -// NewRootCommand returns a new command registered in the parent. -func NewRootCommand(parent common.Registerer, globals *config.Data) *RootCommand { - var c RootCommand - c.Globals = globals - c.CmdClause = parent.Command("bigquery", "Manipulate Fastly service version BigQuery logging endpoints") - return &c -} - -// Exec implements the command interface. -func (c *RootCommand) Exec(in io.Reader, out io.Writer) error { - panic("unreachable") -} diff --git a/pkg/logging/bigquery/update.go b/pkg/logging/bigquery/update.go deleted file mode 100644 index 06304f2d3..000000000 --- a/pkg/logging/bigquery/update.go +++ /dev/null @@ -1,138 +0,0 @@ -package bigquery - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// UpdateCommand calls the Fastly API to update a BigQuery logging endpoint. -type UpdateCommand struct { - common.Base - manifest manifest.Data - - // required - EndpointName string // Can't shadow common.Base method Name(). - Version int - - // optional - NewName common.OptionalString - ProjectID common.OptionalString - Dataset common.OptionalString - Table common.OptionalString - User common.OptionalString - SecretKey common.OptionalString - Template common.OptionalString - Placement common.OptionalString - ResponseCondition common.OptionalString - Format common.OptionalString - FormatVersion common.OptionalUint -} - -// NewUpdateCommand returns a usable command registered under the parent. -func NewUpdateCommand(parent common.Registerer, globals *config.Data) *UpdateCommand { - var c UpdateCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - - c.CmdClause = parent.Command("update", "Update a BigQuery logging endpoint on a Fastly service version") - - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Version) - c.CmdClause.Flag("name", "The name of the BigQuery logging object").Short('n').Required().StringVar(&c.EndpointName) - - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("new-name", "New name of the BigQuery logging object").Action(c.NewName.Set).StringVar(&c.NewName.Value) - c.CmdClause.Flag("project-id", "Your Google Cloud Platform project ID").Action(c.ProjectID.Set).StringVar(&c.ProjectID.Value) - c.CmdClause.Flag("dataset", "Your BigQuery dataset").Action(c.Dataset.Set).StringVar(&c.Dataset.Value) - c.CmdClause.Flag("table", "Your BigQuery table").Action(c.Table.Set).StringVar(&c.Table.Value) - c.CmdClause.Flag("user", "Your Google Cloud Platform service account email address. The client_email field in your service account authentication JSON.").Action(c.User.Set).StringVar(&c.User.Value) - c.CmdClause.Flag("secret-key", "Your Google Cloud Platform account secret key. The private_key field in your service account authentication JSON.").Action(c.SecretKey.Set).StringVar(&c.SecretKey.Value) - c.CmdClause.Flag("template-suffix", "BigQuery table name suffix template").Action(c.Template.Set).StringVar(&c.Template.Value) - c.CmdClause.Flag("format", "Apache style log formatting. Must produce JSON that matches the schema of your BigQuery table").Action(c.Format.Set).StringVar(&c.Format.Value) - c.CmdClause.Flag("format-version", "The version of the custom logging format used for the configured endpoint. Can be either 2 (the default, version 2 log format) or 1 (the version 1 log format). The logging call gets placed by default in vcl_log if format_version is set to 2 and in vcl_deliver if format_version is set to 1").Action(c.FormatVersion.Set).UintVar(&c.FormatVersion.Value) - c.CmdClause.Flag("placement", "Where in the generated VCL the logging call should be placed, overriding any format_version default. Can be none or waf_debug. This field is not required and has no default value").Action(c.Placement.Set).StringVar(&c.Placement.Value) - c.CmdClause.Flag("response-condition", "The name of an existing condition in the configured endpoint, or leave blank to always execute").Action(c.ResponseCondition.Set).StringVar(&c.ResponseCondition.Value) - return &c -} - -// createInput transforms values parsed from CLI flags into an object to be used by the API client library. -func (c *UpdateCommand) createInput() (*fastly.UpdateBigQueryInput, error) { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return nil, errors.ErrNoServiceID - } - - input := fastly.UpdateBigQueryInput{ - ServiceID: serviceID, - ServiceVersion: c.Version, - Name: c.EndpointName, - } - - if c.NewName.WasSet { - input.NewName = fastly.String(c.NewName.Value) - } - - if c.ProjectID.WasSet { - input.ProjectID = fastly.String(c.ProjectID.Value) - } - - if c.Dataset.WasSet { - input.Dataset = fastly.String(c.Dataset.Value) - } - - if c.Table.WasSet { - input.Table = fastly.String(c.Table.Value) - } - - if c.User.WasSet { - input.User = fastly.String(c.User.Value) - } - - if c.SecretKey.WasSet { - input.SecretKey = fastly.String(c.SecretKey.Value) - } - - if c.Template.WasSet { - input.Template = fastly.String(c.Template.Value) - } - - if c.Format.WasSet { - input.Format = fastly.String(c.Format.Value) - } - - if c.FormatVersion.WasSet { - input.FormatVersion = fastly.Uint(c.FormatVersion.Value) - } - - if c.ResponseCondition.WasSet { - input.ResponseCondition = fastly.String(c.ResponseCondition.Value) - } - - if c.Placement.WasSet { - input.Placement = fastly.String(c.Placement.Value) - } - - return &input, nil -} - -// Exec invokes the application logic for the command. -func (c *UpdateCommand) Exec(in io.Reader, out io.Writer) error { - input, err := c.createInput() - if err != nil { - return err - } - - bq, err := c.Globals.Client.UpdateBigQuery(input) - if err != nil { - return err - } - - text.Success(out, "Updated BigQuery logging endpoint %s (service %s version %d)", bq.Name, bq.ServiceID, bq.ServiceVersion) - return nil -} diff --git a/pkg/logging/cloudfiles/cloudfiles_integration_test.go b/pkg/logging/cloudfiles/cloudfiles_integration_test.go deleted file mode 100644 index 7ffda3345..000000000 --- a/pkg/logging/cloudfiles/cloudfiles_integration_test.go +++ /dev/null @@ -1,479 +0,0 @@ -package cloudfiles_test - -import ( - "bytes" - "errors" - "io" - "net/http" - "strings" - "testing" - - "github.com/fastly/cli/pkg/app" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/mock" - "github.com/fastly/cli/pkg/testutil" - "github.com/fastly/cli/pkg/update" - "github.com/fastly/go-fastly/v3/fastly" -) - -func TestCloudfilesCreate(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "cloudfiles", "create", "--service-id", "123", "--version", "1", "--name", "log", "--bucket", "log", "--access-key", "foo"}, - wantError: "error parsing arguments: required flag --user not provided", - }, - { - args: []string{"logging", "cloudfiles", "create", "--service-id", "123", "--version", "1", "--name", "log", "--user", "username", "--access-key", "foo"}, - wantError: "error parsing arguments: required flag --bucket not provided", - }, - { - args: []string{"logging", "cloudfiles", "create", "--service-id", "123", "--version", "1", "--name", "log", "--user", "username", "--bucket", "log"}, - wantError: "error parsing arguments: required flag --access-key not provided", - }, - { - args: []string{"logging", "cloudfiles", "create", "--service-id", "123", "--version", "1", "--name", "log", "--user", "username", "--bucket", "log", "--access-key", "foo"}, - api: mock.API{CreateCloudfilesFn: createCloudfilesOK}, - wantOutput: "Created Cloudfiles logging endpoint log (service 123 version 1)", - }, - { - args: []string{"logging", "cloudfiles", "create", "--service-id", "123", "--version", "1", "--name", "log", "--user", "username", "--bucket", "log", "--access-key", "foo"}, - api: mock.API{CreateCloudfilesFn: createCloudfilesError}, - wantError: errTest.Error(), - }, - { - args: []string{"logging", "cloudfiles", "create", "--service-id", "123", "--version", "1", "--name", "log", "--user", "username", "--bucket", "log", "--access-key", "foo", "--compression-codec", "zstd", "--gzip-level", "9"}, - wantError: "error parsing arguments: the --compression-codec flag is mutually exclusive with the --gzip-level flag", - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertStringContains(t, out.String(), testcase.wantOutput) - }) - } -} - -func TestCloudfilesList(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "cloudfiles", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListCloudfilesFn: listCloudfilesOK}, - wantOutput: listCloudfilesShortOutput, - }, - { - args: []string{"logging", "cloudfiles", "list", "--service-id", "123", "--version", "1", "--verbose"}, - api: mock.API{ListCloudfilesFn: listCloudfilesOK}, - wantOutput: listCloudfilesVerboseOutput, - }, - { - args: []string{"logging", "cloudfiles", "list", "--service-id", "123", "--version", "1", "-v"}, - api: mock.API{ListCloudfilesFn: listCloudfilesOK}, - wantOutput: listCloudfilesVerboseOutput, - }, - { - args: []string{"logging", "cloudfiles", "--verbose", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListCloudfilesFn: listCloudfilesOK}, - wantOutput: listCloudfilesVerboseOutput, - }, - { - args: []string{"logging", "-v", "cloudfiles", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListCloudfilesFn: listCloudfilesOK}, - wantOutput: listCloudfilesVerboseOutput, - }, - { - args: []string{"logging", "cloudfiles", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListCloudfilesFn: listCloudfilesError}, - wantError: errTest.Error(), - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertString(t, testcase.wantOutput, out.String()) - }) - } -} - -func TestCloudfilesDescribe(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "cloudfiles", "describe", "--service-id", "123", "--version", "1"}, - wantError: "error parsing arguments: required flag --name not provided", - }, - { - args: []string{"logging", "cloudfiles", "describe", "--service-id", "123", "--version", "1", "--name", "logs"}, - api: mock.API{GetCloudfilesFn: getCloudfilesError}, - wantError: errTest.Error(), - }, - { - args: []string{"logging", "cloudfiles", "describe", "--service-id", "123", "--version", "1", "--name", "logs"}, - api: mock.API{GetCloudfilesFn: getCloudfilesOK}, - wantOutput: describeCloudfilesOutput, - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertString(t, testcase.wantOutput, out.String()) - }) - } -} - -func TestCloudfilesUpdate(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "cloudfiles", "update", "--service-id", "123", "--version", "1", "--new-name", "log"}, - wantError: "error parsing arguments: required flag --name not provided", - }, - { - args: []string{"logging", "cloudfiles", "update", "--service-id", "123", "--version", "1", "--name", "logs", "--new-name", "log"}, - api: mock.API{UpdateCloudfilesFn: updateCloudfilesError}, - wantError: errTest.Error(), - }, - { - args: []string{"logging", "cloudfiles", "update", "--service-id", "123", "--version", "1", "--name", "logs", "--new-name", "log"}, - api: mock.API{UpdateCloudfilesFn: updateCloudfilesOK}, - wantOutput: "Updated Cloudfiles logging endpoint log (service 123 version 1)", - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertStringContains(t, out.String(), testcase.wantOutput) - }) - } -} - -func TestCloudfilesDelete(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "cloudfiles", "delete", "--service-id", "123", "--version", "1"}, - wantError: "error parsing arguments: required flag --name not provided", - }, - { - args: []string{"logging", "cloudfiles", "delete", "--service-id", "123", "--version", "1", "--name", "logs"}, - api: mock.API{DeleteCloudfilesFn: deleteCloudfilesError}, - wantError: errTest.Error(), - }, - { - args: []string{"logging", "cloudfiles", "delete", "--service-id", "123", "--version", "1", "--name", "logs"}, - api: mock.API{DeleteCloudfilesFn: deleteCloudfilesOK}, - wantOutput: "Deleted Cloudfiles logging endpoint logs (service 123 version 1)", - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertStringContains(t, out.String(), testcase.wantOutput) - }) - } -} - -var errTest = errors.New("fixture error") - -func createCloudfilesOK(i *fastly.CreateCloudfilesInput) (*fastly.Cloudfiles, error) { - s := fastly.Cloudfiles{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - } - - if i.Name != "" { - s.Name = i.Name - } - - return &s, nil -} - -func createCloudfilesError(i *fastly.CreateCloudfilesInput) (*fastly.Cloudfiles, error) { - return nil, errTest -} - -func listCloudfilesOK(i *fastly.ListCloudfilesInput) ([]*fastly.Cloudfiles, error) { - return []*fastly.Cloudfiles{ - { - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "logs", - User: "username", - AccessKey: "1234", - BucketName: "my-logs", - Path: "logs/", - Region: "ORD", - Placement: "none", - Period: 3600, - GzipLevel: 9, - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - ResponseCondition: "Prevent default logging", - MessageType: "classic", - TimestampFormat: "%Y-%m-%dT%H:%M:%S.000", - PublicKey: pgpPublicKey(), - }, - { - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "analytics", - User: "username", - AccessKey: "1234", - BucketName: "analytics", - Path: "logs/", - Region: "ORD", - Placement: "none", - Period: 86400, - GzipLevel: 9, - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - ResponseCondition: "Prevent default logging", - MessageType: "classic", - TimestampFormat: "%Y-%m-%dT%H:%M:%S.000", - PublicKey: pgpPublicKey(), - }, - }, nil -} - -func listCloudfilesError(i *fastly.ListCloudfilesInput) ([]*fastly.Cloudfiles, error) { - return nil, errTest -} - -var listCloudfilesShortOutput = strings.TrimSpace(` -SERVICE VERSION NAME -123 1 logs -123 1 analytics -`) + "\n" - -var listCloudfilesVerboseOutput = strings.TrimSpace(` -Fastly API token not provided -Fastly API endpoint: https://api.fastly.com -Service ID: 123 -Version: 1 - Cloudfiles 1/2 - Service ID: 123 - Version: 1 - Name: logs - User: username - Access key: 1234 - Bucket: my-logs - Path: logs/ - Region: ORD - Placement: none - Period: 3600 - GZip level: 9 - Format: %h %l %u %t "%r" %>s %b - Format version: 2 - Response condition: Prevent default logging - Message type: classic - Timestamp format: %Y-%m-%dT%H:%M:%S.000 - Public key: `+pgpPublicKey()+` - Cloudfiles 2/2 - Service ID: 123 - Version: 1 - Name: analytics - User: username - Access key: 1234 - Bucket: analytics - Path: logs/ - Region: ORD - Placement: none - Period: 86400 - GZip level: 9 - Format: %h %l %u %t "%r" %>s %b - Format version: 2 - Response condition: Prevent default logging - Message type: classic - Timestamp format: %Y-%m-%dT%H:%M:%S.000 - Public key: `+pgpPublicKey()+` -`) + "\n\n" - -func getCloudfilesOK(i *fastly.GetCloudfilesInput) (*fastly.Cloudfiles, error) { - return &fastly.Cloudfiles{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "logs", - User: "username", - AccessKey: "1234", - BucketName: "my-logs", - Path: "logs/", - Region: "ORD", - Placement: "none", - Period: 3600, - GzipLevel: 9, - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - ResponseCondition: "Prevent default logging", - MessageType: "classic", - TimestampFormat: "%Y-%m-%dT%H:%M:%S.000", - PublicKey: pgpPublicKey(), - }, nil -} - -func getCloudfilesError(i *fastly.GetCloudfilesInput) (*fastly.Cloudfiles, error) { - return nil, errTest -} - -var describeCloudfilesOutput = strings.TrimSpace(` -Service ID: 123 -Version: 1 -Name: logs -User: username -Access key: 1234 -Bucket: my-logs -Path: logs/ -Region: ORD -Placement: none -Period: 3600 -GZip level: 9 -Format: %h %l %u %t "%r" %>s %b -Format version: 2 -Response condition: Prevent default logging -Message type: classic -Timestamp format: %Y-%m-%dT%H:%M:%S.000 -Public key: `+pgpPublicKey()+` -`) + "\n" - -func updateCloudfilesOK(i *fastly.UpdateCloudfilesInput) (*fastly.Cloudfiles, error) { - return &fastly.Cloudfiles{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "log", - User: "username", - AccessKey: "1234", - BucketName: "my-logs", - Path: "logs/", - Region: "ORD", - Placement: "none", - Period: 3600, - GzipLevel: 9, - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - ResponseCondition: "Prevent default logging", - MessageType: "classic", - TimestampFormat: "%Y-%m-%dT%H:%M:%S.000", - PublicKey: pgpPublicKey(), - }, nil -} - -func updateCloudfilesError(i *fastly.UpdateCloudfilesInput) (*fastly.Cloudfiles, error) { - return nil, errTest -} - -func deleteCloudfilesOK(i *fastly.DeleteCloudfilesInput) error { - return nil -} - -func deleteCloudfilesError(i *fastly.DeleteCloudfilesInput) error { - return errTest -} - -// pgpPublicKey returns a PEM encoded PGP public key suitable for testing. -func pgpPublicKey() string { - return strings.TrimSpace(`-----BEGIN PGP PUBLIC KEY BLOCK----- -mQENBFyUD8sBCACyFnB39AuuTygseek+eA4fo0cgwva6/FSjnWq7riouQee8GgQ/ -ibXTRyv4iVlwI12GswvMTIy7zNvs1R54i0qvsLr+IZ4GVGJqs6ZJnvQcqe3xPoR4 -8AnBfw90o32r/LuHf6QCJXi+AEu35koNlNAvLJ2B+KACaNB7N0EeWmqpV/1V2k9p -lDYk+th7LcCuaFNGqKS/PrMnnMqR6VDLCjHhNx4KR79b0Twm/2qp6an3hyNRu8Gn -dwxpf1/BUu3JWf+LqkN4Y3mbOmSUL3MaJNvyQguUzTfS0P0uGuBDHrJCVkMZCzDB -89ag55jCPHyGeHBTd02gHMWzsg3WMBWvCsrzABEBAAG0JXRlcnJhZm9ybSAodGVz -dCkgPHRlc3RAdGVycmFmb3JtLmNvbT6JAU4EEwEIADgWIQSHYyc6Kj9l6HzQsau6 -vFFc9jxV/wUCXJQPywIbAwULCQgHAgYVCgkICwIEFgIDAQIeAQIXgAAKCRC6vFFc -9jxV/815CAClb32OxV7wG01yF97TzlyTl8TnvjMtoG29Mw4nSyg+mjM3b8N7iXm9 -OLX59fbDAWtBSldSZE22RXd3CvlFOG/EnKBXSjBtEqfyxYSnyOPkMPBYWGL/ApkX -SvPYJ4LKdvipYToKFh3y9kk2gk1DcDBDyaaHvR+3rv1u3aoy7/s2EltAfDS3ZQIq -7/cWTLJml/lleeB/Y6rPj8xqeCYhE5ahw9gsV/Mdqatl24V9Tks30iijx0Hhw+Gx -kATUikMGr2GDVqoIRga5kXI7CzYff4rkc0Twn47fMHHHe/KY9M2yVnMHUXmAZwbG -M1cMI/NH1DjevCKdGBLcRJlhuLPKF/anuQENBFyUD8sBCADIpd7r7GuPd6n/Ikxe -u6h7umV6IIPoAm88xCYpTbSZiaK30Svh6Ywra9jfE2KlU9o6Y/art8ip0VJ3m07L -4RSfSpnzqgSwdjSq5hNour2Fo/BzYhK7yaz2AzVSbe33R0+RYhb4b/6N+bKbjwGF -ftCsqVFMH+PyvYkLbvxyQrHlA9woAZaNThI1ztO5rGSnGUR8xt84eup28WIFKg0K -UEGUcTzz+8QGAwAra+0ewPXo/AkO+8BvZjDidP417u6gpBHOJ9qYIcO9FxHeqFyu -YrjlrxowEgXn5wO8xuNz6Vu1vhHGDHGDsRbZF8pv1d5O+0F1G7ttZ2GRRgVBZPwi -kiyRABEBAAGJATYEGAEIACAWIQSHYyc6Kj9l6HzQsau6vFFc9jxV/wUCXJQPywIb -DAAKCRC6vFFc9jxV/9YOCACe8qmOSnKQpQfW+PqYOqo3dt7JyweTs3FkD6NT8Zml -dYy/vkstbTjPpX6aTvUZjkb46BVi7AOneVHpD5GBqvRsZ9iVgDYHaehmLCdKiG5L -3Tp90NN+QY5WDbsGmsyk6+6ZMYejb4qYfweQeduOj27aavCJdLkCYMoRKfcFYI8c -FaNmEfKKy/r1PO20NXEG6t9t05K/frHy6ZG8bCNYdpagfFVot47r9JaQqWlTNtIR -5+zkkSq/eG9BEtRij3a6cTdQbktdBzx2KBeI0PYc1vlZR0LpuFKZqY9vlE6vTGLR -wMfrTEOvx0NxUM3rpaCgEmuWbB1G1Hu371oyr4srrr+N -=28dr ------END PGP PUBLIC KEY BLOCK----- -`) -} diff --git a/pkg/logging/cloudfiles/cloudfiles_test.go b/pkg/logging/cloudfiles/cloudfiles_test.go deleted file mode 100644 index 72094c70a..000000000 --- a/pkg/logging/cloudfiles/cloudfiles_test.go +++ /dev/null @@ -1,269 +0,0 @@ -package cloudfiles - -import ( - "strings" - "testing" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/mock" - "github.com/fastly/cli/pkg/testutil" - "github.com/fastly/go-fastly/v3/fastly" -) - -func TestCreateCloudfilesInput(t *testing.T) { - for _, testcase := range []struct { - name string - cmd *CreateCommand - want *fastly.CreateCloudfilesInput - wantError string - }{ - { - name: "required values set flag serviceID", - cmd: createCommandRequired(), - want: &fastly.CreateCloudfilesInput{ - ServiceID: "123", - ServiceVersion: 2, - Name: "log", - User: "user", - AccessKey: "key", - BucketName: "bucket", - }, - }, - { - name: "all values set flag serviceID", - cmd: createCommandAll(), - want: &fastly.CreateCloudfilesInput{ - ServiceID: "123", - ServiceVersion: 2, - Name: "log", - User: "user", - AccessKey: "key", - BucketName: "bucket", - Path: "/logs", - Region: "abc", - Placement: "none", - Period: 3600, - GzipLevel: 0, - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - ResponseCondition: "Prevent default logging", - MessageType: "classic", - TimestampFormat: "%Y-%m-%dT%H:%M:%S.000", - PublicKey: pgpPublicKey(), - CompressionCodec: "zstd", - }, - }, - { - name: "error missing serviceID", - cmd: createCommandMissingServiceID(), - want: nil, - wantError: errors.ErrNoServiceID.Error(), - }, - } { - t.Run(testcase.name, func(t *testing.T) { - have, err := testcase.cmd.createInput() - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertEqual(t, testcase.want, have) - }) - } -} - -func TestUpdateCloudfilesInput(t *testing.T) { - for _, testcase := range []struct { - name string - cmd *UpdateCommand - api mock.API - want *fastly.UpdateCloudfilesInput - wantError string - }{ - { - name: "no update", - cmd: updateCommandNoUpdate(), - api: mock.API{GetCloudfilesFn: getCloudfilesOK}, - want: &fastly.UpdateCloudfilesInput{ - ServiceID: "123", - ServiceVersion: 2, - Name: "log", - }, - }, - { - name: "all values set flag serviceID", - cmd: updateCommandAll(), - api: mock.API{GetCloudfilesFn: getCloudfilesOK}, - want: &fastly.UpdateCloudfilesInput{ - ServiceID: "123", - ServiceVersion: 2, - Name: "log", - NewName: fastly.String("new1"), - AccessKey: fastly.String("new2"), - BucketName: fastly.String("new3"), - Path: fastly.String("new4"), - Region: fastly.String("new5"), - Placement: fastly.String("new6"), - Period: fastly.Uint(3601), - GzipLevel: fastly.Uint(0), - Format: fastly.String("new7"), - FormatVersion: fastly.Uint(3), - ResponseCondition: fastly.String("new8"), - MessageType: fastly.String("new9"), - TimestampFormat: fastly.String("new10"), - PublicKey: fastly.String("new11"), - User: fastly.String("new12"), - CompressionCodec: fastly.String("new13"), - }, - }, - { - name: "error missing serviceID", - cmd: updateCommandMissingServiceID(), - want: nil, - wantError: errors.ErrNoServiceID.Error(), - }, - } { - t.Run(testcase.name, func(t *testing.T) { - testcase.cmd.Base.Globals.Client = testcase.api - - have, err := testcase.cmd.createInput() - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertEqual(t, testcase.want, have) - }) - } -} - -func createCommandRequired() *CreateCommand { - return &CreateCommand{ - manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, - EndpointName: "log", - Version: 2, - User: "user", - AccessKey: "key", - BucketName: "bucket", - } -} - -func createCommandAll() *CreateCommand { - return &CreateCommand{ - manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, - EndpointName: "log", - Version: 2, - User: "user", - AccessKey: "key", - BucketName: "bucket", - Path: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "/logs"}, - Region: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "abc"}, - Placement: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "none"}, - Period: common.OptionalUint{Optional: common.Optional{WasSet: true}, Value: 3600}, - Format: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: `%h %l %u %t "%r" %>s %b`}, - FormatVersion: common.OptionalUint{Optional: common.Optional{WasSet: true}, Value: 2}, - ResponseCondition: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "Prevent default logging"}, - MessageType: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "classic"}, - TimestampFormat: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "%Y-%m-%dT%H:%M:%S.000"}, - PublicKey: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: pgpPublicKey()}, - CompressionCodec: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "zstd"}, - } -} - -func createCommandMissingServiceID() *CreateCommand { - res := createCommandAll() - res.manifest = manifest.Data{} - return res -} - -func updateCommandNoUpdate() *UpdateCommand { - return &UpdateCommand{ - Base: common.Base{Globals: &config.Data{Client: nil}}, - manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, - Version: 2, - EndpointName: "log", - } -} - -func updateCommandAll() *UpdateCommand { - return &UpdateCommand{ - Base: common.Base{Globals: &config.Data{Client: nil}}, - manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, - Version: 2, - EndpointName: "log", - NewName: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new1"}, - AccessKey: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new2"}, - BucketName: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new3"}, - Path: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new4"}, - Region: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new5"}, - Placement: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new6"}, - Period: common.OptionalUint{Optional: common.Optional{WasSet: true}, Value: 3601}, - GzipLevel: common.OptionalUint{Optional: common.Optional{WasSet: true}, Value: 0}, - Format: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new7"}, - FormatVersion: common.OptionalUint{Optional: common.Optional{WasSet: true}, Value: 3}, - ResponseCondition: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new8"}, - MessageType: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new9"}, - TimestampFormat: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new10"}, - PublicKey: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new11"}, - User: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new12"}, - CompressionCodec: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new13"}, - } -} - -func updateCommandMissingServiceID() *UpdateCommand { - res := updateCommandAll() - res.manifest = manifest.Data{} - return res -} - -func getCloudfilesOK(i *fastly.GetCloudfilesInput) (*fastly.Cloudfiles, error) { - return &fastly.Cloudfiles{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "logs", - User: "user", - AccessKey: "key", - BucketName: "bucket", - Path: "/logs", - Region: "abc", - Placement: "none", - Period: 3600, - GzipLevel: 0, - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - ResponseCondition: "Prevent default logging", - MessageType: "classic", - TimestampFormat: "%Y-%m-%dT%H:%M:%S.000", - PublicKey: pgpPublicKey(), - CompressionCodec: "zstd", - }, nil -} - -// pgpPublicKey returns a PEM encoded PGP public key suitable for testing. -func pgpPublicKey() string { - return strings.TrimSpace(`-----BEGIN PGP PUBLIC KEY BLOCK----- -mQENBFyUD8sBCACyFnB39AuuTygseek+eA4fo0cgwva6/FSjnWq7riouQee8GgQ/ -ibXTRyv4iVlwI12GswvMTIy7zNvs1R54i0qvsLr+IZ4GVGJqs6ZJnvQcqe3xPoR4 -8AnBfw90o32r/LuHf6QCJXi+AEu35koNlNAvLJ2B+KACaNB7N0EeWmqpV/1V2k9p -lDYk+th7LcCuaFNGqKS/PrMnnMqR6VDLCjHhNx4KR79b0Twm/2qp6an3hyNRu8Gn -dwxpf1/BUu3JWf+LqkN4Y3mbOmSUL3MaJNvyQguUzTfS0P0uGuBDHrJCVkMZCzDB -89ag55jCPHyGeHBTd02gHMWzsg3WMBWvCsrzABEBAAG0JXRlcnJhZm9ybSAodGVz -dCkgPHRlc3RAdGVycmFmb3JtLmNvbT6JAU4EEwEIADgWIQSHYyc6Kj9l6HzQsau6 -vFFc9jxV/wUCXJQPywIbAwULCQgHAgYVCgkICwIEFgIDAQIeAQIXgAAKCRC6vFFc -9jxV/815CAClb32OxV7wG01yF97TzlyTl8TnvjMtoG29Mw4nSyg+mjM3b8N7iXm9 -OLX59fbDAWtBSldSZE22RXd3CvlFOG/EnKBXSjBtEqfyxYSnyOPkMPBYWGL/ApkX -SvPYJ4LKdvipYToKFh3y9kk2gk1DcDBDyaaHvR+3rv1u3aoy7/s2EltAfDS3ZQIq -7/cWTLJml/lleeB/Y6rPj8xqeCYhE5ahw9gsV/Mdqatl24V9Tks30iijx0Hhw+Gx -kATUikMGr2GDVqoIRga5kXI7CzYff4rkc0Twn47fMHHHe/KY9M2yVnMHUXmAZwbG -M1cMI/NH1DjevCKdGBLcRJlhuLPKF/anuQENBFyUD8sBCADIpd7r7GuPd6n/Ikxe -u6h7umV6IIPoAm88xCYpTbSZiaK30Svh6Ywra9jfE2KlU9o6Y/art8ip0VJ3m07L -4RSfSpnzqgSwdjSq5hNour2Fo/BzYhK7yaz2AzVSbe33R0+RYhb4b/6N+bKbjwGF -ftCsqVFMH+PyvYkLbvxyQrHlA9woAZaNThI1ztO5rGSnGUR8xt84eup28WIFKg0K -UEGUcTzz+8QGAwAra+0ewPXo/AkO+8BvZjDidP417u6gpBHOJ9qYIcO9FxHeqFyu -YrjlrxowEgXn5wO8xuNz6Vu1vhHGDHGDsRbZF8pv1d5O+0F1G7ttZ2GRRgVBZPwi -kiyRABEBAAGJATYEGAEIACAWIQSHYyc6Kj9l6HzQsau6vFFc9jxV/wUCXJQPywIb -DAAKCRC6vFFc9jxV/9YOCACe8qmOSnKQpQfW+PqYOqo3dt7JyweTs3FkD6NT8Zml -dYy/vkstbTjPpX6aTvUZjkb46BVi7AOneVHpD5GBqvRsZ9iVgDYHaehmLCdKiG5L -3Tp90NN+QY5WDbsGmsyk6+6ZMYejb4qYfweQeduOj27aavCJdLkCYMoRKfcFYI8c -FaNmEfKKy/r1PO20NXEG6t9t05K/frHy6ZG8bCNYdpagfFVot47r9JaQqWlTNtIR -5+zkkSq/eG9BEtRij3a6cTdQbktdBzx2KBeI0PYc1vlZR0LpuFKZqY9vlE6vTGLR -wMfrTEOvx0NxUM3rpaCgEmuWbB1G1Hu371oyr4srrr+N -=28dr ------END PGP PUBLIC KEY BLOCK----- -`) -} diff --git a/pkg/logging/cloudfiles/create.go b/pkg/logging/cloudfiles/create.go deleted file mode 100644 index 0dbe017a5..000000000 --- a/pkg/logging/cloudfiles/create.go +++ /dev/null @@ -1,162 +0,0 @@ -package cloudfiles - -import ( - "fmt" - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// CreateCommand calls the Fastly API to create a Cloudfiles logging endpoint. -type CreateCommand struct { - common.Base - manifest manifest.Data - - // required - EndpointName string // Can't shadow common.Base method Name(). - Token string - Version int - User string - AccessKey string - BucketName string - - // optional - Path common.OptionalString - Region common.OptionalString - PublicKey common.OptionalString - Period common.OptionalUint - GzipLevel common.OptionalUint - MessageType common.OptionalString - TimestampFormat common.OptionalString - Format common.OptionalString - FormatVersion common.OptionalUint - ResponseCondition common.OptionalString - Placement common.OptionalString - CompressionCodec common.OptionalString -} - -// NewCreateCommand returns a usable command registered under the parent. -func NewCreateCommand(parent common.Registerer, globals *config.Data) *CreateCommand { - var c CreateCommand - - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("create", "Create a Cloudfiles logging endpoint on a Fastly service version").Alias("add") - - c.CmdClause.Flag("name", "The name of the Cloudfiles logging object. Used as a primary key for API access").Short('n').Required().StringVar(&c.EndpointName) - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Version) - c.CmdClause.Flag("user", "The username for your Cloudfile account").Required().StringVar(&c.User) - c.CmdClause.Flag("access-key", "Your Cloudfile account access key").Required().StringVar(&c.AccessKey) - c.CmdClause.Flag("bucket", "The name of your Cloudfiles container").Required().StringVar(&c.BucketName) - - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("path", "The path to upload logs to").Action(c.Path.Set).StringVar(&c.Path.Value) - c.CmdClause.Flag("region", "The region to stream logs to. One of: DFW-Dallas, ORD-Chicago, IAD-Northern Virginia, LON-London, SYD-Sydney, HKG-Hong Kong").Action(c.Region.Set).StringVar(&c.Region.Value) - c.CmdClause.Flag("placement", "Where in the generated VCL the logging call should be placed, overriding any format_version default. Can be none or waf_debug").Action(c.Placement.Set).StringVar(&c.Placement.Value) - c.CmdClause.Flag("period", "How frequently log files are finalized so they can be available for reading (in seconds, default 3600)").Action(c.Period.Set).UintVar(&c.Period.Value) - c.CmdClause.Flag("gzip-level", "What level of GZIP encoding to have when dumping logs (default 0, no compression)").Action(c.GzipLevel.Set).UintVar(&c.GzipLevel.Value) - c.CmdClause.Flag("format", "Apache style log formatting").Action(c.Format.Set).StringVar(&c.Format.Value) - c.CmdClause.Flag("format-version", "The version of the custom logging format used for the configured endpoint. Can be either 2 (default) or 1").Action(c.FormatVersion.Set).UintVar(&c.FormatVersion.Value) - c.CmdClause.Flag("response-condition", "The name of an existing condition in the configured endpoint, or leave blank to always execute").Action(c.ResponseCondition.Set).StringVar(&c.ResponseCondition.Value) - c.CmdClause.Flag("message-type", "How the message should be formatted. One of: classic (default), loggly, logplex or blank").Action(c.MessageType.Set).StringVar(&c.MessageType.Value) - c.CmdClause.Flag("timestamp-format", `strftime specified timestamp formatting (default "%Y-%m-%dT%H:%M:%S.000")`).Action(c.TimestampFormat.Set).StringVar(&c.TimestampFormat.Value) - c.CmdClause.Flag("public-key", "A PGP public key that Fastly will use to encrypt your log files before writing them to disk").Action(c.PublicKey.Set).StringVar(&c.PublicKey.Value) - c.CmdClause.Flag("compression-codec", `The codec used for compression of your logs. Valid values are zstd, snappy, and gzip. If the specified codec is "gzip", gzip_level will default to 3. To specify a different level, leave compression_codec blank and explicitly set the level using gzip_level. Specifying both compression_codec and gzip_level in the same API request will result in an error.`).Action(c.CompressionCodec.Set).StringVar(&c.CompressionCodec.Value) - - return &c -} - -// createInput transforms values parsed from CLI flags into an object to be used by the API client library. -func (c *CreateCommand) createInput() (*fastly.CreateCloudfilesInput, error) { - var input fastly.CreateCloudfilesInput - - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return nil, errors.ErrNoServiceID - } - - input.ServiceID = serviceID - input.ServiceVersion = c.Version - input.Name = c.EndpointName - input.User = c.User - input.AccessKey = c.AccessKey - input.BucketName = c.BucketName - - // The following blocks enforces the mutual exclusivity of the - // CompressionCodec and GzipLevel flags. - if c.CompressionCodec.WasSet && c.GzipLevel.WasSet { - return nil, fmt.Errorf("error parsing arguments: the --compression-codec flag is mutually exclusive with the --gzip-level flag") - } - - if c.Path.WasSet { - input.Path = c.Path.Value - } - - if c.Region.WasSet { - input.Region = c.Region.Value - } - - if c.Placement.WasSet { - input.Placement = c.Placement.Value - } - - if c.Period.WasSet { - input.Period = c.Period.Value - } - - if c.GzipLevel.WasSet { - input.GzipLevel = c.GzipLevel.Value - } - - if c.Format.WasSet { - input.Format = c.Format.Value - } - - if c.FormatVersion.WasSet { - input.FormatVersion = c.FormatVersion.Value - } - - if c.ResponseCondition.WasSet { - input.ResponseCondition = c.ResponseCondition.Value - } - - if c.MessageType.WasSet { - input.MessageType = c.MessageType.Value - } - - if c.TimestampFormat.WasSet { - input.TimestampFormat = c.TimestampFormat.Value - } - - if c.PublicKey.WasSet { - input.PublicKey = c.PublicKey.Value - } - - if c.CompressionCodec.WasSet { - input.CompressionCodec = c.CompressionCodec.Value - } - - return &input, nil -} - -// Exec invokes the application logic for the command. -func (c *CreateCommand) Exec(in io.Reader, out io.Writer) error { - input, err := c.createInput() - if err != nil { - return err - } - - d, err := c.Globals.Client.CreateCloudfiles(input) - if err != nil { - return err - } - - text.Success(out, "Created Cloudfiles logging endpoint %s (service %s version %d)", d.Name, d.ServiceID, d.ServiceVersion) - return nil -} diff --git a/pkg/logging/cloudfiles/delete.go b/pkg/logging/cloudfiles/delete.go deleted file mode 100644 index d16a9ffd2..000000000 --- a/pkg/logging/cloudfiles/delete.go +++ /dev/null @@ -1,51 +0,0 @@ -package cloudfiles - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// DeleteCommand calls the Fastly API to delete a Cloudfiles logging endpoint. -type DeleteCommand struct { - common.Base - manifest manifest.Data - Input fastly.DeleteCloudfilesInput -} - -// NewDeleteCommand returns a usable command registered under the parent. -func NewDeleteCommand(parent common.Registerer, globals *config.Data) *DeleteCommand { - var c DeleteCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("delete", "Delete a Cloudfiles logging endpoint on a Fastly service version").Alias("remove") - - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Input.ServiceVersion) - c.CmdClause.Flag("name", "The name of the Cloudfiles logging object").Short('n').Required().StringVar(&c.Input.Name) - - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - - return &c -} - -// Exec invokes the application logic for the command. -func (c *DeleteCommand) Exec(in io.Reader, out io.Writer) error { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - c.Input.ServiceID = serviceID - - if err := c.Globals.Client.DeleteCloudfiles(&c.Input); err != nil { - return err - } - - text.Success(out, "Deleted Cloudfiles logging endpoint %s (service %s version %d)", c.Input.Name, c.Input.ServiceID, c.Input.ServiceVersion) - return nil -} diff --git a/pkg/logging/cloudfiles/describe.go b/pkg/logging/cloudfiles/describe.go deleted file mode 100644 index 027f16f01..000000000 --- a/pkg/logging/cloudfiles/describe.go +++ /dev/null @@ -1,66 +0,0 @@ -package cloudfiles - -import ( - "fmt" - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/go-fastly/v3/fastly" -) - -// DescribeCommand calls the Fastly API to describe a Cloudfiles logging endpoint. -type DescribeCommand struct { - common.Base - manifest manifest.Data - Input fastly.GetCloudfilesInput -} - -// NewDescribeCommand returns a usable command registered under the parent. -func NewDescribeCommand(parent common.Registerer, globals *config.Data) *DescribeCommand { - var c DescribeCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("describe", "Show detailed information about a Cloudfiles logging endpoint on a Fastly service version").Alias("get") - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Input.ServiceVersion) - c.CmdClause.Flag("name", "The name of the Cloudfiles logging object").Short('n').Required().StringVar(&c.Input.Name) - return &c -} - -// Exec invokes the application logic for the command. -func (c *DescribeCommand) Exec(in io.Reader, out io.Writer) error { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - c.Input.ServiceID = serviceID - - cloudfiles, err := c.Globals.Client.GetCloudfiles(&c.Input) - if err != nil { - return err - } - - fmt.Fprintf(out, "Service ID: %s\n", cloudfiles.ServiceID) - fmt.Fprintf(out, "Version: %d\n", cloudfiles.ServiceVersion) - fmt.Fprintf(out, "Name: %s\n", cloudfiles.Name) - fmt.Fprintf(out, "User: %s\n", cloudfiles.User) - fmt.Fprintf(out, "Access key: %s\n", cloudfiles.AccessKey) - fmt.Fprintf(out, "Bucket: %s\n", cloudfiles.BucketName) - fmt.Fprintf(out, "Path: %s\n", cloudfiles.Path) - fmt.Fprintf(out, "Region: %s\n", cloudfiles.Region) - fmt.Fprintf(out, "Placement: %s\n", cloudfiles.Placement) - fmt.Fprintf(out, "Period: %d\n", cloudfiles.Period) - fmt.Fprintf(out, "GZip level: %d\n", cloudfiles.GzipLevel) - fmt.Fprintf(out, "Format: %s\n", cloudfiles.Format) - fmt.Fprintf(out, "Format version: %d\n", cloudfiles.FormatVersion) - fmt.Fprintf(out, "Response condition: %s\n", cloudfiles.ResponseCondition) - fmt.Fprintf(out, "Message type: %s\n", cloudfiles.MessageType) - fmt.Fprintf(out, "Timestamp format: %s\n", cloudfiles.TimestampFormat) - fmt.Fprintf(out, "Public key: %s\n", cloudfiles.PublicKey) - - return nil -} diff --git a/pkg/logging/cloudfiles/list.go b/pkg/logging/cloudfiles/list.go deleted file mode 100644 index 7df2eef86..000000000 --- a/pkg/logging/cloudfiles/list.go +++ /dev/null @@ -1,82 +0,0 @@ -package cloudfiles - -import ( - "fmt" - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// ListCommand calls the Fastly API to list Cloudfiles logging endpoints. -type ListCommand struct { - common.Base - manifest manifest.Data - Input fastly.ListCloudfilesInput -} - -// NewListCommand returns a usable command registered under the parent. -func NewListCommand(parent common.Registerer, globals *config.Data) *ListCommand { - var c ListCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("list", "List Cloudfiles endpoints on a Fastly service version") - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Input.ServiceVersion) - return &c -} - -// Exec invokes the application logic for the command. -func (c *ListCommand) Exec(in io.Reader, out io.Writer) error { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - c.Input.ServiceID = serviceID - - cloudfiles, err := c.Globals.Client.ListCloudfiles(&c.Input) - if err != nil { - return err - } - - if !c.Globals.Verbose() { - tw := text.NewTable(out) - tw.AddHeader("SERVICE", "VERSION", "NAME") - for _, cloudfile := range cloudfiles { - tw.AddLine(cloudfile.ServiceID, cloudfile.ServiceVersion, cloudfile.Name) - } - tw.Print() - return nil - } - - fmt.Fprintf(out, "Service ID: %s\n", c.Input.ServiceID) - fmt.Fprintf(out, "Version: %d\n", c.Input.ServiceVersion) - for i, cloudfile := range cloudfiles { - fmt.Fprintf(out, "\tCloudfiles %d/%d\n", i+1, len(cloudfiles)) - fmt.Fprintf(out, "\t\tService ID: %s\n", cloudfile.ServiceID) - fmt.Fprintf(out, "\t\tVersion: %d\n", cloudfile.ServiceVersion) - fmt.Fprintf(out, "\t\tName: %s\n", cloudfile.Name) - fmt.Fprintf(out, "\t\tUser: %s\n", cloudfile.User) - fmt.Fprintf(out, "\t\tAccess key: %s\n", cloudfile.AccessKey) - fmt.Fprintf(out, "\t\tBucket: %s\n", cloudfile.BucketName) - fmt.Fprintf(out, "\t\tPath: %s\n", cloudfile.Path) - fmt.Fprintf(out, "\t\tRegion: %s\n", cloudfile.Region) - fmt.Fprintf(out, "\t\tPlacement: %s\n", cloudfile.Placement) - fmt.Fprintf(out, "\t\tPeriod: %d\n", cloudfile.Period) - fmt.Fprintf(out, "\t\tGZip level: %d\n", cloudfile.GzipLevel) - fmt.Fprintf(out, "\t\tFormat: %s\n", cloudfile.Format) - fmt.Fprintf(out, "\t\tFormat version: %d\n", cloudfile.FormatVersion) - fmt.Fprintf(out, "\t\tResponse condition: %s\n", cloudfile.ResponseCondition) - fmt.Fprintf(out, "\t\tMessage type: %s\n", cloudfile.MessageType) - fmt.Fprintf(out, "\t\tTimestamp format: %s\n", cloudfile.TimestampFormat) - fmt.Fprintf(out, "\t\tPublic key: %s\n", cloudfile.PublicKey) - } - fmt.Fprintln(out) - - return nil -} diff --git a/pkg/logging/cloudfiles/root.go b/pkg/logging/cloudfiles/root.go deleted file mode 100644 index b0d8f7c50..000000000 --- a/pkg/logging/cloudfiles/root.go +++ /dev/null @@ -1,28 +0,0 @@ -package cloudfiles - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/config" -) - -// RootCommand is the parent command for all subcommands in this package. -// It should be installed under the primary root command. -type RootCommand struct { - common.Base - // no flags -} - -// NewRootCommand returns a new command registered in the parent. -func NewRootCommand(parent common.Registerer, globals *config.Data) *RootCommand { - var c RootCommand - c.Globals = globals - c.CmdClause = parent.Command("cloudfiles", "Manipulate Fastly service version Cloudfiles logging endpoints") - return &c -} - -// Exec implements the command interface. -func (c *RootCommand) Exec(in io.Reader, out io.Writer) error { - panic("unreachable") -} diff --git a/pkg/logging/cloudfiles/update.go b/pkg/logging/cloudfiles/update.go deleted file mode 100644 index 57e203c49..000000000 --- a/pkg/logging/cloudfiles/update.go +++ /dev/null @@ -1,170 +0,0 @@ -package cloudfiles - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// UpdateCommand calls the Fastly API to update a Cloudfiles logging endpoint. -type UpdateCommand struct { - common.Base - manifest manifest.Data - - // required - EndpointName string // Can't shadow common.Base method Name(). - Version int - - // optional - NewName common.OptionalString - User common.OptionalString - AccessKey common.OptionalString - BucketName common.OptionalString - Path common.OptionalString - Region common.OptionalString - Placement common.OptionalString - Period common.OptionalUint - GzipLevel common.OptionalUint - Format common.OptionalString - FormatVersion common.OptionalUint - ResponseCondition common.OptionalString - MessageType common.OptionalString - TimestampFormat common.OptionalString - PublicKey common.OptionalString - CompressionCodec common.OptionalString -} - -// NewUpdateCommand returns a usable command registered under the parent. -func NewUpdateCommand(parent common.Registerer, globals *config.Data) *UpdateCommand { - var c UpdateCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - - c.CmdClause = parent.Command("update", "Update a Cloudfiles logging endpoint on a Fastly service version") - - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Version) - c.CmdClause.Flag("name", "The name of the Cloudfiles logging object").Short('n').Required().StringVar(&c.EndpointName) - - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("new-name", "New name of the Cloudfiles logging object").Action(c.NewName.Set).StringVar(&c.NewName.Value) - c.CmdClause.Flag("user", "The username for your Cloudfile account").Action(c.User.Set).StringVar(&c.User.Value) - c.CmdClause.Flag("access-key", "Your Cloudfile account access key").Action(c.AccessKey.Set).StringVar(&c.AccessKey.Value) - c.CmdClause.Flag("bucket", "The name of your Cloudfiles container").Action(c.BucketName.Set).StringVar(&c.BucketName.Value) - c.CmdClause.Flag("path", "The path to upload logs to").Action(c.Path.Set).StringVar(&c.Path.Value) - c.CmdClause.Flag("region", "The region to stream logs to. One of: DFW-Dallas, ORD-Chicago, IAD-Northern Virginia, LON-London, SYD-Sydney, HKG-Hong Kong").Action(c.Region.Set).StringVar(&c.Region.Value) - c.CmdClause.Flag("placement", "Where in the generated VCL the logging call should be placed, overriding any format_version default. Can be none or waf_debug").Action(c.Placement.Set).StringVar(&c.Placement.Value) - c.CmdClause.Flag("period", "How frequently log files are finalized so they can be available for reading (in seconds, default 3600)").Action(c.Period.Set).UintVar(&c.Period.Value) - c.CmdClause.Flag("gzip-level", "What level of GZIP encoding to have when dumping logs (default 0, no compression)").Action(c.GzipLevel.Set).UintVar(&c.GzipLevel.Value) - c.CmdClause.Flag("format", "Apache style log formatting").Action(c.Format.Set).StringVar(&c.Format.Value) - c.CmdClause.Flag("format-version", "The version of the custom logging format used for the configured endpoint. Can be either 2 (default) or 1").Action(c.FormatVersion.Set).UintVar(&c.FormatVersion.Value) - c.CmdClause.Flag("response-condition", "The name of an existing condition in the configured endpoint, or leave blank to always execute").Action(c.ResponseCondition.Set).StringVar(&c.ResponseCondition.Value) - c.CmdClause.Flag("message-type", "How the message should be formatted. One of: classic (default), loggly, logplex or blank").Action(c.MessageType.Set).StringVar(&c.MessageType.Value) - c.CmdClause.Flag("timestamp-format", `strftime specified timestamp formatting (default "%Y-%m-%dT%H:%M:%S.000")`).Action(c.TimestampFormat.Set).StringVar(&c.TimestampFormat.Value) - c.CmdClause.Flag("public-key", "A PGP public key that Fastly will use to encrypt your log files before writing them to disk").Action(c.PublicKey.Set).StringVar(&c.PublicKey.Value) - c.CmdClause.Flag("compression-codec", `The codec used for compression of your logs. Valid values are zstd, snappy, and gzip. If the specified codec is "gzip", gzip_level will default to 3. To specify a different level, leave compression_codec blank and explicitly set the level using gzip_level. Specifying both compression_codec and gzip_level in the same API request will result in an error.`).Action(c.CompressionCodec.Set).StringVar(&c.CompressionCodec.Value) - - return &c -} - -// createInput transforms values parsed from CLI flags into an object to be used by the API client library. -func (c *UpdateCommand) createInput() (*fastly.UpdateCloudfilesInput, error) { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return nil, errors.ErrNoServiceID - } - - input := fastly.UpdateCloudfilesInput{ - ServiceID: serviceID, - ServiceVersion: c.Version, - Name: c.EndpointName, - } - - // Set new values if set by user. - if c.NewName.WasSet { - input.NewName = fastly.String(c.NewName.Value) - } - - if c.User.WasSet { - input.User = fastly.String(c.User.Value) - } - - if c.AccessKey.WasSet { - input.AccessKey = fastly.String(c.AccessKey.Value) - } - - if c.BucketName.WasSet { - input.BucketName = fastly.String(c.BucketName.Value) - } - - if c.Path.WasSet { - input.Path = fastly.String(c.Path.Value) - } - - if c.Region.WasSet { - input.Region = fastly.String(c.Region.Value) - } - - if c.Placement.WasSet { - input.Placement = fastly.String(c.Placement.Value) - } - - if c.Period.WasSet { - input.Period = fastly.Uint(c.Period.Value) - } - - if c.GzipLevel.WasSet { - input.GzipLevel = fastly.Uint(c.GzipLevel.Value) - } - - if c.Format.WasSet { - input.Format = fastly.String(c.Format.Value) - } - - if c.FormatVersion.WasSet { - input.FormatVersion = fastly.Uint(c.FormatVersion.Value) - } - - if c.ResponseCondition.WasSet { - input.ResponseCondition = fastly.String(c.ResponseCondition.Value) - } - - if c.MessageType.WasSet { - input.MessageType = fastly.String(c.MessageType.Value) - } - - if c.TimestampFormat.WasSet { - input.TimestampFormat = fastly.String(c.TimestampFormat.Value) - } - - if c.PublicKey.WasSet { - input.PublicKey = fastly.String(c.PublicKey.Value) - } - - if c.CompressionCodec.WasSet { - input.CompressionCodec = fastly.String(c.CompressionCodec.Value) - } - - return &input, nil -} - -// Exec invokes the application logic for the command. -func (c *UpdateCommand) Exec(in io.Reader, out io.Writer) error { - input, err := c.createInput() - if err != nil { - return err - } - - cloudfiles, err := c.Globals.Client.UpdateCloudfiles(input) - if err != nil { - return err - } - - text.Success(out, "Updated Cloudfiles logging endpoint %s (service %s version %d)", cloudfiles.Name, cloudfiles.ServiceID, cloudfiles.ServiceVersion) - return nil -} diff --git a/pkg/logging/datadog/create.go b/pkg/logging/datadog/create.go deleted file mode 100644 index 43e1058b5..000000000 --- a/pkg/logging/datadog/create.go +++ /dev/null @@ -1,106 +0,0 @@ -package datadog - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// CreateCommand calls the Fastly API to create a Datadog logging endpoint. -type CreateCommand struct { - common.Base - manifest manifest.Data - - // required - EndpointName string // Can't shadow common.Base method Name(). - Token string - Version int - - // optional - Region common.OptionalString - Format common.OptionalString - FormatVersion common.OptionalUint - ResponseCondition common.OptionalString - Placement common.OptionalString -} - -// NewCreateCommand returns a usable command registered under the parent. -func NewCreateCommand(parent common.Registerer, globals *config.Data) *CreateCommand { - var c CreateCommand - - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("create", "Create a Datadog logging endpoint on a Fastly service version").Alias("add") - - c.CmdClause.Flag("name", "The name of the Datadog logging object. Used as a primary key for API access").Short('n').Required().StringVar(&c.EndpointName) - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Version) - c.CmdClause.Flag("auth-token", "The API key from your Datadog account").Required().StringVar(&c.Token) - - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("region", "The region that log data will be sent to. One of US or EU. Defaults to US if undefined").Action(c.Region.Set).StringVar(&c.Region.Value) - c.CmdClause.Flag("format", "Apache style log formatting. For details on the default value refer to the documentation (https://developer.fastly.com/reference/api/logging/datadog/)").Action(c.Format.Set).StringVar(&c.Format.Value) - c.CmdClause.Flag("format-version", "The version of the custom logging format used for the configured endpoint. Can be either 2 (default) or 1").Action(c.FormatVersion.Set).UintVar(&c.FormatVersion.Value) - c.CmdClause.Flag("response-condition", "The name of an existing condition in the configured endpoint, or leave blank to always execute").Action(c.ResponseCondition.Set).StringVar(&c.ResponseCondition.Value) - c.CmdClause.Flag("placement", "Where in the generated VCL the logging call should be placed, overriding any format_version default. Can be none or waf_debug").Action(c.Placement.Set).StringVar(&c.Placement.Value) - - return &c -} - -// createInput transforms values parsed from CLI flags into an object to be used by the API client library. -func (c *CreateCommand) createInput() (*fastly.CreateDatadogInput, error) { - var input fastly.CreateDatadogInput - - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return nil, errors.ErrNoServiceID - } - - input.ServiceID = serviceID - input.ServiceVersion = c.Version - input.Name = c.EndpointName - input.Token = c.Token - - if c.Region.WasSet { - input.Region = c.Region.Value - } - - if c.Format.WasSet { - input.Format = c.Format.Value - } - - if c.FormatVersion.WasSet { - input.FormatVersion = c.FormatVersion.Value - } - - if c.ResponseCondition.WasSet { - input.ResponseCondition = c.ResponseCondition.Value - } - - if c.Placement.WasSet { - input.Placement = c.Placement.Value - } - - return &input, nil -} - -// Exec invokes the application logic for the command. -func (c *CreateCommand) Exec(in io.Reader, out io.Writer) error { - input, err := c.createInput() - if err != nil { - return err - } - - d, err := c.Globals.Client.CreateDatadog(input) - if err != nil { - return err - } - - text.Success(out, "Created Datadog logging endpoint %s (service %s version %d)", d.Name, d.ServiceID, d.ServiceVersion) - return nil -} diff --git a/pkg/logging/datadog/datadog_integration_test.go b/pkg/logging/datadog/datadog_integration_test.go deleted file mode 100644 index 6b701b889..000000000 --- a/pkg/logging/datadog/datadog_integration_test.go +++ /dev/null @@ -1,376 +0,0 @@ -package datadog_test - -import ( - "bytes" - "errors" - "io" - "net/http" - "strings" - "testing" - - "github.com/fastly/cli/pkg/app" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/mock" - "github.com/fastly/cli/pkg/testutil" - "github.com/fastly/cli/pkg/update" - "github.com/fastly/go-fastly/v3/fastly" -) - -func TestDatadogCreate(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "datadog", "create", "--service-id", "123", "--version", "1", "--name", "log"}, - wantError: "error parsing arguments: required flag --auth-token not provided", - }, - { - args: []string{"logging", "datadog", "create", "--service-id", "123", "--version", "1", "--name", "log", "--auth-token", "abc"}, - api: mock.API{CreateDatadogFn: createDatadogOK}, - wantOutput: "Created Datadog logging endpoint log (service 123 version 1)", - }, - { - args: []string{"logging", "datadog", "create", "--service-id", "123", "--version", "1", "--name", "log", "--auth-token", "abc"}, - api: mock.API{CreateDatadogFn: createDatadogError}, - wantError: errTest.Error(), - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertStringContains(t, out.String(), testcase.wantOutput) - }) - } -} - -func TestDatadogList(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "datadog", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListDatadogFn: listDatadogsOK}, - wantOutput: listDatadogsShortOutput, - }, - { - args: []string{"logging", "datadog", "list", "--service-id", "123", "--version", "1", "--verbose"}, - api: mock.API{ListDatadogFn: listDatadogsOK}, - wantOutput: listDatadogsVerboseOutput, - }, - { - args: []string{"logging", "datadog", "list", "--service-id", "123", "--version", "1", "-v"}, - api: mock.API{ListDatadogFn: listDatadogsOK}, - wantOutput: listDatadogsVerboseOutput, - }, - { - args: []string{"logging", "datadog", "--verbose", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListDatadogFn: listDatadogsOK}, - wantOutput: listDatadogsVerboseOutput, - }, - { - args: []string{"logging", "-v", "datadog", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListDatadogFn: listDatadogsOK}, - wantOutput: listDatadogsVerboseOutput, - }, - { - args: []string{"logging", "datadog", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListDatadogFn: listDatadogsError}, - wantError: errTest.Error(), - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertString(t, testcase.wantOutput, out.String()) - }) - } -} - -func TestDatadogDescribe(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "datadog", "describe", "--service-id", "123", "--version", "1"}, - wantError: "error parsing arguments: required flag --name not provided", - }, - { - args: []string{"logging", "datadog", "describe", "--service-id", "123", "--version", "1", "--name", "logs"}, - api: mock.API{GetDatadogFn: getDatadogError}, - wantError: errTest.Error(), - }, - { - args: []string{"logging", "datadog", "describe", "--service-id", "123", "--version", "1", "--name", "logs"}, - api: mock.API{GetDatadogFn: getDatadogOK}, - wantOutput: describeDatadogOutput, - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertString(t, testcase.wantOutput, out.String()) - }) - } -} - -func TestDatadogUpdate(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "datadog", "update", "--service-id", "123", "--version", "1", "--new-name", "log"}, - wantError: "error parsing arguments: required flag --name not provided", - }, - { - args: []string{"logging", "datadog", "update", "--service-id", "123", "--version", "1", "--name", "logs", "--new-name", "log"}, - api: mock.API{UpdateDatadogFn: updateDatadogError}, - wantError: errTest.Error(), - }, - { - args: []string{"logging", "datadog", "update", "--service-id", "123", "--version", "1", "--name", "logs", "--new-name", "log"}, - api: mock.API{UpdateDatadogFn: updateDatadogOK}, - wantOutput: "Updated Datadog logging endpoint log (service 123 version 1)", - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertStringContains(t, out.String(), testcase.wantOutput) - }) - } -} - -func TestDatadogDelete(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "datadog", "delete", "--service-id", "123", "--version", "1"}, - wantError: "error parsing arguments: required flag --name not provided", - }, - { - args: []string{"logging", "datadog", "delete", "--service-id", "123", "--version", "1", "--name", "logs"}, - api: mock.API{DeleteDatadogFn: deleteDatadogError}, - wantError: errTest.Error(), - }, - { - args: []string{"logging", "datadog", "delete", "--service-id", "123", "--version", "1", "--name", "logs"}, - api: mock.API{DeleteDatadogFn: deleteDatadogOK}, - wantOutput: "Deleted Datadog logging endpoint logs (service 123 version 1)", - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertStringContains(t, out.String(), testcase.wantOutput) - }) - } -} - -var errTest = errors.New("fixture error") - -func createDatadogOK(i *fastly.CreateDatadogInput) (*fastly.Datadog, error) { - s := fastly.Datadog{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - } - - if i.Name != "" { - s.Name = i.Name - } - - return &s, nil -} - -func createDatadogError(i *fastly.CreateDatadogInput) (*fastly.Datadog, error) { - return nil, errTest -} - -func listDatadogsOK(i *fastly.ListDatadogInput) ([]*fastly.Datadog, error) { - return []*fastly.Datadog{ - { - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "logs", - Token: "abc", - Region: "US", - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - ResponseCondition: "Prevent default logging", - Placement: "none", - }, - { - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "analytics", - Token: "abc", - Region: "US", - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - ResponseCondition: "Prevent default logging", - Placement: "none", - }, - }, nil -} - -func listDatadogsError(i *fastly.ListDatadogInput) ([]*fastly.Datadog, error) { - return nil, errTest -} - -var listDatadogsShortOutput = strings.TrimSpace(` -SERVICE VERSION NAME -123 1 logs -123 1 analytics -`) + "\n" - -var listDatadogsVerboseOutput = strings.TrimSpace(` -Fastly API token not provided -Fastly API endpoint: https://api.fastly.com -Service ID: 123 -Version: 1 - Datadog 1/2 - Service ID: 123 - Version: 1 - Name: logs - Token: abc - Region: US - Format: %h %l %u %t "%r" %>s %b - Format version: 2 - Response condition: Prevent default logging - Placement: none - Datadog 2/2 - Service ID: 123 - Version: 1 - Name: analytics - Token: abc - Region: US - Format: %h %l %u %t "%r" %>s %b - Format version: 2 - Response condition: Prevent default logging - Placement: none -`) + "\n\n" - -func getDatadogOK(i *fastly.GetDatadogInput) (*fastly.Datadog, error) { - return &fastly.Datadog{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "logs", - Token: "abc", - Region: "US", - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - ResponseCondition: "Prevent default logging", - Placement: "none", - }, nil -} - -func getDatadogError(i *fastly.GetDatadogInput) (*fastly.Datadog, error) { - return nil, errTest -} - -var describeDatadogOutput = strings.TrimSpace(` -Service ID: 123 -Version: 1 -Name: logs -Token: abc -Region: US -Format: %h %l %u %t "%r" %>s %b -Format version: 2 -Response condition: Prevent default logging -Placement: none -`) + "\n" - -func updateDatadogOK(i *fastly.UpdateDatadogInput) (*fastly.Datadog, error) { - return &fastly.Datadog{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "log", - Token: "abc", - Region: "US", - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - ResponseCondition: "Prevent default logging", - }, nil -} - -func updateDatadogError(i *fastly.UpdateDatadogInput) (*fastly.Datadog, error) { - return nil, errTest -} - -func deleteDatadogOK(i *fastly.DeleteDatadogInput) error { - return nil -} - -func deleteDatadogError(i *fastly.DeleteDatadogInput) error { - return errTest -} diff --git a/pkg/logging/datadog/datadog_test.go b/pkg/logging/datadog/datadog_test.go deleted file mode 100644 index bfaf47763..000000000 --- a/pkg/logging/datadog/datadog_test.go +++ /dev/null @@ -1,186 +0,0 @@ -package datadog - -import ( - "testing" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/mock" - "github.com/fastly/cli/pkg/testutil" - "github.com/fastly/go-fastly/v3/fastly" -) - -func TestCreateDatadogInput(t *testing.T) { - for _, testcase := range []struct { - name string - cmd *CreateCommand - want *fastly.CreateDatadogInput - wantError string - }{ - { - name: "required values set flag serviceID", - cmd: createCommandRequired(), - want: &fastly.CreateDatadogInput{ - ServiceID: "123", - ServiceVersion: 2, - Name: "log", - Token: "tkn", - }, - }, - { - name: "all values set flag serviceID", - cmd: createCommandOK(), - want: &fastly.CreateDatadogInput{ - ServiceID: "123", - ServiceVersion: 2, - Name: "log", - Region: "US", - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - Token: "tkn", - ResponseCondition: "Prevent default logging", - Placement: "none", - }, - }, - { - name: "error missing serviceID", - cmd: createCommandMissingServiceID(), - want: nil, - wantError: errors.ErrNoServiceID.Error(), - }, - } { - t.Run(testcase.name, func(t *testing.T) { - have, err := testcase.cmd.createInput() - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertEqual(t, testcase.want, have) - }) - } -} - -func TestUpdateDatadogInput(t *testing.T) { - for _, testcase := range []struct { - name string - cmd *UpdateCommand - api mock.API - want *fastly.UpdateDatadogInput - wantError string - }{ - { - name: "no updates", - cmd: updateCommandNoUpdates(), - api: mock.API{GetDatadogFn: getDatadogOK}, - want: &fastly.UpdateDatadogInput{ - ServiceID: "123", - ServiceVersion: 2, - Name: "log", - }, - }, - { - name: "all values set flag serviceID", - cmd: updateCommandAll(), - api: mock.API{GetDatadogFn: getDatadogOK}, - want: &fastly.UpdateDatadogInput{ - ServiceID: "123", - ServiceVersion: 2, - Name: "log", - NewName: fastly.String("new1"), - Region: fastly.String("new2"), - Format: fastly.String("new3"), - FormatVersion: fastly.Uint(3), - Token: fastly.String("new4"), - ResponseCondition: fastly.String("new5"), - Placement: fastly.String("new6"), - }, - }, - { - name: "error missing serviceID", - cmd: updateCommandMissingServiceID(), - want: nil, - wantError: errors.ErrNoServiceID.Error(), - }, - } { - t.Run(testcase.name, func(t *testing.T) { - testcase.cmd.Base.Globals.Client = testcase.api - - have, err := testcase.cmd.createInput() - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertEqual(t, testcase.want, have) - }) - } -} - -func createCommandOK() *CreateCommand { - return &CreateCommand{ - manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, - EndpointName: "log", - Token: "tkn", - Version: 2, - Region: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "US"}, - Format: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: `%h %l %u %t "%r" %>s %b`}, - FormatVersion: common.OptionalUint{Optional: common.Optional{WasSet: true}, Value: 2}, - ResponseCondition: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "Prevent default logging"}, - Placement: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "none"}, - } -} - -func createCommandRequired() *CreateCommand { - return &CreateCommand{ - manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, - EndpointName: "log", - Token: "tkn", - Version: 2, - } -} - -func createCommandMissingServiceID() *CreateCommand { - res := createCommandOK() - res.manifest = manifest.Data{} - return res -} - -func updateCommandNoUpdates() *UpdateCommand { - return &UpdateCommand{ - Base: common.Base{Globals: &config.Data{Client: nil}}, - manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, - EndpointName: "log", - Version: 2, - } -} - -func updateCommandAll() *UpdateCommand { - return &UpdateCommand{ - Base: common.Base{Globals: &config.Data{Client: nil}}, - manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, - EndpointName: "log", - Version: 2, - NewName: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new1"}, - Region: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new2"}, - Format: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new3"}, - FormatVersion: common.OptionalUint{Optional: common.Optional{WasSet: true}, Value: 3}, - Token: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new4"}, - ResponseCondition: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new5"}, - Placement: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new6"}, - } -} - -func updateCommandMissingServiceID() *UpdateCommand { - res := updateCommandAll() - res.manifest = manifest.Data{} - return res -} - -func getDatadogOK(i *fastly.GetDatadogInput) (*fastly.Datadog, error) { - return &fastly.Datadog{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "logs", - Token: "tkn", - Region: "US", - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - ResponseCondition: "Prevent default logging", - Placement: "none", - }, nil -} diff --git a/pkg/logging/datadog/delete.go b/pkg/logging/datadog/delete.go deleted file mode 100644 index f87458507..000000000 --- a/pkg/logging/datadog/delete.go +++ /dev/null @@ -1,51 +0,0 @@ -package datadog - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// DeleteCommand calls the Fastly API to delete a Datadog logging endpoint. -type DeleteCommand struct { - common.Base - manifest manifest.Data - Input fastly.DeleteDatadogInput -} - -// NewDeleteCommand returns a usable command registered under the parent. -func NewDeleteCommand(parent common.Registerer, globals *config.Data) *DeleteCommand { - var c DeleteCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("delete", "Delete a Datadog logging endpoint on a Fastly service version").Alias("remove") - - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Input.ServiceVersion) - c.CmdClause.Flag("name", "The name of the Datadog logging object").Short('n').Required().StringVar(&c.Input.Name) - - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - - return &c -} - -// Exec invokes the application logic for the command. -func (c *DeleteCommand) Exec(in io.Reader, out io.Writer) error { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - c.Input.ServiceID = serviceID - - if err := c.Globals.Client.DeleteDatadog(&c.Input); err != nil { - return err - } - - text.Success(out, "Deleted Datadog logging endpoint %s (service %s version %d)", c.Input.Name, c.Input.ServiceID, c.Input.ServiceVersion) - return nil -} diff --git a/pkg/logging/datadog/describe.go b/pkg/logging/datadog/describe.go deleted file mode 100644 index 871fe4ae2..000000000 --- a/pkg/logging/datadog/describe.go +++ /dev/null @@ -1,58 +0,0 @@ -package datadog - -import ( - "fmt" - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/go-fastly/v3/fastly" -) - -// DescribeCommand calls the Fastly API to describe a Datadog logging endpoint. -type DescribeCommand struct { - common.Base - manifest manifest.Data - Input fastly.GetDatadogInput -} - -// NewDescribeCommand returns a usable command registered under the parent. -func NewDescribeCommand(parent common.Registerer, globals *config.Data) *DescribeCommand { - var c DescribeCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("describe", "Show detailed information about a Datadog logging endpoint on a Fastly service version").Alias("get") - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Input.ServiceVersion) - c.CmdClause.Flag("name", "The name of the Datadog logging object").Short('n').Required().StringVar(&c.Input.Name) - return &c -} - -// Exec invokes the application logic for the command. -func (c *DescribeCommand) Exec(in io.Reader, out io.Writer) error { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - c.Input.ServiceID = serviceID - - datadog, err := c.Globals.Client.GetDatadog(&c.Input) - if err != nil { - return err - } - - fmt.Fprintf(out, "Service ID: %s\n", datadog.ServiceID) - fmt.Fprintf(out, "Version: %d\n", datadog.ServiceVersion) - fmt.Fprintf(out, "Name: %s\n", datadog.Name) - fmt.Fprintf(out, "Token: %s\n", datadog.Token) - fmt.Fprintf(out, "Region: %s\n", datadog.Region) - fmt.Fprintf(out, "Format: %s\n", datadog.Format) - fmt.Fprintf(out, "Format version: %d\n", datadog.FormatVersion) - fmt.Fprintf(out, "Response condition: %s\n", datadog.ResponseCondition) - fmt.Fprintf(out, "Placement: %s\n", datadog.Placement) - - return nil -} diff --git a/pkg/logging/datadog/list.go b/pkg/logging/datadog/list.go deleted file mode 100644 index b09010fad..000000000 --- a/pkg/logging/datadog/list.go +++ /dev/null @@ -1,74 +0,0 @@ -package datadog - -import ( - "fmt" - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// ListCommand calls the Fastly API to list Datadog logging endpoints. -type ListCommand struct { - common.Base - manifest manifest.Data - Input fastly.ListDatadogInput -} - -// NewListCommand returns a usable command registered under the parent. -func NewListCommand(parent common.Registerer, globals *config.Data) *ListCommand { - var c ListCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("list", "List Datadog endpoints on a Fastly service version") - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Input.ServiceVersion) - return &c -} - -// Exec invokes the application logic for the command. -func (c *ListCommand) Exec(in io.Reader, out io.Writer) error { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - c.Input.ServiceID = serviceID - - datadogs, err := c.Globals.Client.ListDatadog(&c.Input) - if err != nil { - return err - } - - if !c.Globals.Verbose() { - tw := text.NewTable(out) - tw.AddHeader("SERVICE", "VERSION", "NAME") - for _, datadog := range datadogs { - tw.AddLine(datadog.ServiceID, datadog.ServiceVersion, datadog.Name) - } - tw.Print() - return nil - } - - fmt.Fprintf(out, "Service ID: %s\n", c.Input.ServiceID) - fmt.Fprintf(out, "Version: %d\n", c.Input.ServiceVersion) - for i, datadog := range datadogs { - fmt.Fprintf(out, "\tDatadog %d/%d\n", i+1, len(datadogs)) - fmt.Fprintf(out, "\t\tService ID: %s\n", datadog.ServiceID) - fmt.Fprintf(out, "\t\tVersion: %d\n", datadog.ServiceVersion) - fmt.Fprintf(out, "\t\tName: %s\n", datadog.Name) - fmt.Fprintf(out, "\t\tToken: %s\n", datadog.Token) - fmt.Fprintf(out, "\t\tRegion: %s\n", datadog.Region) - fmt.Fprintf(out, "\t\tFormat: %s\n", datadog.Format) - fmt.Fprintf(out, "\t\tFormat version: %d\n", datadog.FormatVersion) - fmt.Fprintf(out, "\t\tResponse condition: %s\n", datadog.ResponseCondition) - fmt.Fprintf(out, "\t\tPlacement: %s\n", datadog.Placement) - } - fmt.Fprintln(out) - - return nil -} diff --git a/pkg/logging/datadog/root.go b/pkg/logging/datadog/root.go deleted file mode 100644 index 1d27dfc55..000000000 --- a/pkg/logging/datadog/root.go +++ /dev/null @@ -1,28 +0,0 @@ -package datadog - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/config" -) - -// RootCommand is the parent command for all subcommands in this package. -// It should be installed under the primary root command. -type RootCommand struct { - common.Base - // no flags -} - -// NewRootCommand returns a new command registered in the parent. -func NewRootCommand(parent common.Registerer, globals *config.Data) *RootCommand { - var c RootCommand - c.Globals = globals - c.CmdClause = parent.Command("datadog", "Manipulate Fastly service version Datadog logging endpoints") - return &c -} - -// Exec implements the command interface. -func (c *RootCommand) Exec(in io.Reader, out io.Writer) error { - panic("unreachable") -} diff --git a/pkg/logging/datadog/update.go b/pkg/logging/datadog/update.go deleted file mode 100644 index f9436695d..000000000 --- a/pkg/logging/datadog/update.go +++ /dev/null @@ -1,115 +0,0 @@ -package datadog - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// UpdateCommand calls the Fastly API to update a Datadog logging endpoint. -type UpdateCommand struct { - common.Base - manifest manifest.Data - - // required - EndpointName string // Can't shadow common.Base method Name(). - Version int - - // optional - NewName common.OptionalString - Token common.OptionalString - Region common.OptionalString - Format common.OptionalString - FormatVersion common.OptionalUint - ResponseCondition common.OptionalString - Placement common.OptionalString -} - -// NewUpdateCommand returns a usable command registered under the parent. -func NewUpdateCommand(parent common.Registerer, globals *config.Data) *UpdateCommand { - var c UpdateCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - - c.CmdClause = parent.Command("update", "Update a Datadog logging endpoint on a Fastly service version") - - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Version) - c.CmdClause.Flag("name", "The name of the Datadog logging object").Short('n').Required().StringVar(&c.EndpointName) - - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("new-name", "New name of the Datadog logging object").Action(c.NewName.Set).StringVar(&c.NewName.Value) - c.CmdClause.Flag("auth-token", "The API key from your Datadog account").Action(c.Token.Set).StringVar(&c.Token.Value) - c.CmdClause.Flag("region", "The region that log data will be sent to. One of US or EU. Defaults to US if undefined").Action(c.Region.Set).StringVar(&c.Region.Value) - c.CmdClause.Flag("format", "Apache style log formatting. For details on the default value refer to the documentation (https://developer.fastly.com/reference/api/logging/datadog/)").Action(c.Format.Set).StringVar(&c.Format.Value) - c.CmdClause.Flag("format-version", "The version of the custom logging format used for the configured endpoint. Can be either 2 (default) or 1").Action(c.FormatVersion.Set).UintVar(&c.FormatVersion.Value) - c.CmdClause.Flag("response-condition", "The name of an existing condition in the configured endpoint, or leave blank to always execute").Action(c.ResponseCondition.Set).StringVar(&c.ResponseCondition.Value) - c.CmdClause.Flag("placement", "Where in the generated VCL the logging call should be placed, overriding any format_version default. Can be none or waf_debug").Action(c.Placement.Set).StringVar(&c.Placement.Value) - - return &c -} - -// createInput transforms values parsed from CLI flags into an object to be used by the API client library. -func (c *UpdateCommand) createInput() (*fastly.UpdateDatadogInput, error) { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return nil, errors.ErrNoServiceID - } - - input := fastly.UpdateDatadogInput{ - ServiceID: serviceID, - ServiceVersion: c.Version, - Name: c.EndpointName, - } - - if c.NewName.WasSet { - input.NewName = fastly.String(c.NewName.Value) - } - - if c.Token.WasSet { - input.Token = fastly.String(c.Token.Value) - } - - if c.Region.WasSet { - input.Region = fastly.String(c.Region.Value) - } - - if c.Format.WasSet { - input.Format = fastly.String(c.Format.Value) - } - - if c.FormatVersion.WasSet { - input.FormatVersion = fastly.Uint(c.FormatVersion.Value) - } - - if c.ResponseCondition.WasSet { - input.ResponseCondition = fastly.String(c.ResponseCondition.Value) - } - - if c.Placement.WasSet { - input.Placement = fastly.String(c.Placement.Value) - } - - return &input, nil -} - -// Exec invokes the application logic for the command. -func (c *UpdateCommand) Exec(in io.Reader, out io.Writer) error { - input, err := c.createInput() - if err != nil { - return err - } - - datadog, err := c.Globals.Client.UpdateDatadog(input) - if err != nil { - return err - } - - text.Success(out, "Updated Datadog logging endpoint %s (service %s version %d)", datadog.Name, datadog.ServiceID, datadog.ServiceVersion) - return nil -} diff --git a/pkg/logging/digitalocean/create.go b/pkg/logging/digitalocean/create.go deleted file mode 100644 index f1137a43f..000000000 --- a/pkg/logging/digitalocean/create.go +++ /dev/null @@ -1,161 +0,0 @@ -package digitalocean - -import ( - "fmt" - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// CreateCommand calls the Fastly API to create a DigitalOcean Spaces logging endpoint. -type CreateCommand struct { - common.Base - manifest manifest.Data - - // required - EndpointName string // Can't shadow common.Base method Name(). - Version int - BucketName string - AccessKey string - SecretKey string - - // optional - Domain common.OptionalString - Path common.OptionalString - Period common.OptionalUint - GzipLevel common.OptionalUint - MessageType common.OptionalString - Format common.OptionalString - FormatVersion common.OptionalUint - ResponseCondition common.OptionalString - TimestampFormat common.OptionalString - Placement common.OptionalString - PublicKey common.OptionalString - CompressionCodec common.OptionalString -} - -// NewCreateCommand returns a usable command registered under the parent. -func NewCreateCommand(parent common.Registerer, globals *config.Data) *CreateCommand { - var c CreateCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("create", "Create a DigitalOcean Spaces logging endpoint on a Fastly service version").Alias("add") - - c.CmdClause.Flag("name", "The name of the DigitalOcean Spaces logging object. Used as a primary key for API access").Short('n').Required().StringVar(&c.EndpointName) - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Version) - - c.CmdClause.Flag("bucket", "The name of the DigitalOcean Space").Required().StringVar(&c.BucketName) - c.CmdClause.Flag("access-key", "Your DigitalOcean Spaces account access key").Required().StringVar(&c.AccessKey) - c.CmdClause.Flag("secret-key", "Your DigitalOcean Spaces account secret key").Required().StringVar(&c.SecretKey) - - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("domain", "The domain of the DigitalOcean Spaces endpoint (default 'nyc3.digitaloceanspaces.com')").Action(c.Domain.Set).StringVar(&c.Domain.Value) - c.CmdClause.Flag("path", "The path to upload logs to").Action(c.Path.Set).StringVar(&c.Path.Value) - c.CmdClause.Flag("period", "How frequently log files are finalized so they can be available for reading (in seconds, default 3600)").Action(c.Period.Set).UintVar(&c.Period.Value) - c.CmdClause.Flag("gzip-level", "What level of GZIP encoding to have when dumping logs (default 0, no compression)").Action(c.GzipLevel.Set).UintVar(&c.GzipLevel.Value) - c.CmdClause.Flag("format", "Apache style log formatting").Action(c.Format.Set).StringVar(&c.Format.Value) - c.CmdClause.Flag("message-type", "How the message should be formatted. One of: classic (default), loggly, logplex or blank").Action(c.MessageType.Set).StringVar(&c.MessageType.Value) - c.CmdClause.Flag("format-version", "The version of the custom logging format used for the configured endpoint. Can be either 2 (default) or 1").Action(c.FormatVersion.Set).UintVar(&c.FormatVersion.Value) - c.CmdClause.Flag("response-condition", "The name of an existing condition in the configured endpoint, or leave blank to always execute").Action(c.ResponseCondition.Set).StringVar(&c.ResponseCondition.Value) - c.CmdClause.Flag("timestamp-format", `strftime specified timestamp formatting (default "%Y-%m-%dT%H:%M:%S.000")`).Action(c.TimestampFormat.Set).StringVar(&c.TimestampFormat.Value) - c.CmdClause.Flag("placement", "Where in the generated VCL the logging call should be placed, overriding any format_version default. Can be none or waf_debug").Action(c.Placement.Set).StringVar(&c.Placement.Value) - c.CmdClause.Flag("public-key", "A PGP public key that Fastly will use to encrypt your log files before writing them to disk").Action(c.PublicKey.Set).StringVar(&c.PublicKey.Value) - c.CmdClause.Flag("compression-codec", `The codec used for compression of your logs. Valid values are zstd, snappy, and gzip. If the specified codec is "gzip", gzip_level will default to 3. To specify a different level, leave compression_codec blank and explicitly set the level using gzip_level. Specifying both compression_codec and gzip_level in the same API request will result in an error.`).Action(c.CompressionCodec.Set).StringVar(&c.CompressionCodec.Value) - - return &c -} - -// createInput transforms values parsed from CLI flags into an object to be used by the API client library. -func (c *CreateCommand) createInput() (*fastly.CreateDigitalOceanInput, error) { - var input fastly.CreateDigitalOceanInput - - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return nil, errors.ErrNoServiceID - } - - input.ServiceID = serviceID - input.ServiceVersion = c.Version - input.Name = c.EndpointName - input.BucketName = c.BucketName - input.AccessKey = c.AccessKey - input.SecretKey = c.SecretKey - - // The following blocks enforces the mutual exclusivity of the - // CompressionCodec and GzipLevel flags. - if c.CompressionCodec.WasSet && c.GzipLevel.WasSet { - return nil, fmt.Errorf("error parsing arguments: the --compression-codec flag is mutually exclusive with the --gzip-level flag") - } - - if c.Domain.WasSet { - input.Domain = c.Domain.Value - } - - if c.Path.WasSet { - input.Path = c.Path.Value - } - - if c.Period.WasSet { - input.Period = c.Period.Value - } - - if c.GzipLevel.WasSet { - input.GzipLevel = c.GzipLevel.Value - } - - if c.Format.WasSet { - input.Format = c.Format.Value - } - - if c.FormatVersion.WasSet { - input.FormatVersion = c.FormatVersion.Value - } - - if c.ResponseCondition.WasSet { - input.ResponseCondition = c.ResponseCondition.Value - } - - if c.MessageType.WasSet { - input.MessageType = c.MessageType.Value - } - - if c.TimestampFormat.WasSet { - input.TimestampFormat = c.TimestampFormat.Value - } - - if c.Placement.WasSet { - input.Placement = c.Placement.Value - } - - if c.PublicKey.WasSet { - input.PublicKey = c.PublicKey.Value - } - - if c.CompressionCodec.WasSet { - input.CompressionCodec = c.CompressionCodec.Value - } - - return &input, nil -} - -// Exec invokes the application logic for the command. -func (c *CreateCommand) Exec(in io.Reader, out io.Writer) error { - input, err := c.createInput() - if err != nil { - return err - } - - d, err := c.Globals.Client.CreateDigitalOcean(input) - if err != nil { - return err - } - - text.Success(out, "Created DigitalOcean Spaces logging endpoint %s (service %s version %d)", d.Name, d.ServiceID, d.ServiceVersion) - return nil -} diff --git a/pkg/logging/digitalocean/delete.go b/pkg/logging/digitalocean/delete.go deleted file mode 100644 index 05292a6d6..000000000 --- a/pkg/logging/digitalocean/delete.go +++ /dev/null @@ -1,51 +0,0 @@ -package digitalocean - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// DeleteCommand calls the Fastly API to delete a DigitalOcean Spaces logging endpoint. -type DeleteCommand struct { - common.Base - manifest manifest.Data - Input fastly.DeleteDigitalOceanInput -} - -// NewDeleteCommand returns a usable command registered under the parent. -func NewDeleteCommand(parent common.Registerer, globals *config.Data) *DeleteCommand { - var c DeleteCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("delete", "Delete a DigitalOcean Spaces logging endpoint on a Fastly service version").Alias("remove") - - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Input.ServiceVersion) - c.CmdClause.Flag("name", "The name of the DigitalOcean Spaces logging object").Short('n').Required().StringVar(&c.Input.Name) - - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - - return &c -} - -// Exec invokes the application logic for the command. -func (c *DeleteCommand) Exec(in io.Reader, out io.Writer) error { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - c.Input.ServiceID = serviceID - - if err := c.Globals.Client.DeleteDigitalOcean(&c.Input); err != nil { - return err - } - - text.Success(out, "Deleted DigitalOcean Spaces logging endpoint %s (service %s version %d)", c.Input.Name, c.Input.ServiceID, c.Input.ServiceVersion) - return nil -} diff --git a/pkg/logging/digitalocean/describe.go b/pkg/logging/digitalocean/describe.go deleted file mode 100644 index a8208349d..000000000 --- a/pkg/logging/digitalocean/describe.go +++ /dev/null @@ -1,66 +0,0 @@ -package digitalocean - -import ( - "fmt" - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/go-fastly/v3/fastly" -) - -// DescribeCommand calls the Fastly API to describe a DigitalOcean Spaces logging endpoint. -type DescribeCommand struct { - common.Base - manifest manifest.Data - Input fastly.GetDigitalOceanInput -} - -// NewDescribeCommand returns a usable command registered under the parent. -func NewDescribeCommand(parent common.Registerer, globals *config.Data) *DescribeCommand { - var c DescribeCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("describe", "Show detailed information about a DigitalOcean Spaces logging endpoint on a Fastly service version").Alias("get") - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Input.ServiceVersion) - c.CmdClause.Flag("name", "The name of the DigitalOcean Spaces logging object").Short('n').Required().StringVar(&c.Input.Name) - return &c -} - -// Exec invokes the application logic for the command. -func (c *DescribeCommand) Exec(in io.Reader, out io.Writer) error { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - c.Input.ServiceID = serviceID - - digitalocean, err := c.Globals.Client.GetDigitalOcean(&c.Input) - if err != nil { - return err - } - - fmt.Fprintf(out, "Service ID: %s\n", digitalocean.ServiceID) - fmt.Fprintf(out, "Version: %d\n", digitalocean.ServiceVersion) - fmt.Fprintf(out, "Name: %s\n", digitalocean.Name) - fmt.Fprintf(out, "Bucket: %s\n", digitalocean.BucketName) - fmt.Fprintf(out, "Domain: %s\n", digitalocean.Domain) - fmt.Fprintf(out, "Access key: %s\n", digitalocean.AccessKey) - fmt.Fprintf(out, "Secret key: %s\n", digitalocean.SecretKey) - fmt.Fprintf(out, "Path: %s\n", digitalocean.Path) - fmt.Fprintf(out, "Period: %d\n", digitalocean.Period) - fmt.Fprintf(out, "GZip level: %d\n", digitalocean.GzipLevel) - fmt.Fprintf(out, "Format: %s\n", digitalocean.Format) - fmt.Fprintf(out, "Format version: %d\n", digitalocean.FormatVersion) - fmt.Fprintf(out, "Response condition: %s\n", digitalocean.ResponseCondition) - fmt.Fprintf(out, "Message type: %s\n", digitalocean.MessageType) - fmt.Fprintf(out, "Timestamp format: %s\n", digitalocean.TimestampFormat) - fmt.Fprintf(out, "Placement: %s\n", digitalocean.Placement) - fmt.Fprintf(out, "Public key: %s\n", digitalocean.PublicKey) - - return nil -} diff --git a/pkg/logging/digitalocean/digitalocean_integration_test.go b/pkg/logging/digitalocean/digitalocean_integration_test.go deleted file mode 100644 index 219be5ddc..000000000 --- a/pkg/logging/digitalocean/digitalocean_integration_test.go +++ /dev/null @@ -1,479 +0,0 @@ -package digitalocean_test - -import ( - "bytes" - "errors" - "io" - "net/http" - "strings" - "testing" - - "github.com/fastly/cli/pkg/app" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/mock" - "github.com/fastly/cli/pkg/testutil" - "github.com/fastly/cli/pkg/update" - "github.com/fastly/go-fastly/v3/fastly" -) - -func TestDigitalOceanCreate(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "digitalocean", "create", "--service-id", "123", "--version", "1", "--name", "log", "--access-key", "foo", "--secret-key", "abc"}, - wantError: "error parsing arguments: required flag --bucket not provided", - }, - { - args: []string{"logging", "digitalocean", "create", "--service-id", "123", "--version", "1", "--name", "log", "--bucket", "log", "--secret-key", "abc"}, - wantError: "error parsing arguments: required flag --access-key not provided", - }, - { - args: []string{"logging", "digitalocean", "create", "--service-id", "123", "--version", "1", "--name", "log", "--bucket", "log", "--access-key", "foo"}, - wantError: "error parsing arguments: required flag --secret-key not provided", - }, - { - args: []string{"logging", "digitalocean", "create", "--service-id", "123", "--version", "1", "--name", "log", "--bucket", "log", "--access-key", "foo", "--secret-key", "abc"}, - api: mock.API{CreateDigitalOceanFn: createDigitalOceanOK}, - wantOutput: "Created DigitalOcean Spaces logging endpoint log (service 123 version 1)", - }, - { - args: []string{"logging", "digitalocean", "create", "--service-id", "123", "--version", "1", "--name", "log", "--bucket", "log", "--access-key", "foo", "--secret-key", "abc"}, - api: mock.API{CreateDigitalOceanFn: createDigitalOceanError}, - wantError: errTest.Error(), - }, - { - args: []string{"logging", "digitalocean", "create", "--service-id", "123", "--version", "1", "--name", "log", "--bucket", "log", "--access-key", "foo", "--secret-key", "abc", "--compression-codec", "zstd", "--gzip-level", "9"}, - wantError: "error parsing arguments: the --compression-codec flag is mutually exclusive with the --gzip-level flag", - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertStringContains(t, out.String(), testcase.wantOutput) - }) - } -} - -func TestDigitalOceanList(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "digitalocean", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListDigitalOceansFn: listDigitalOceansOK}, - wantOutput: listDigitalOceansShortOutput, - }, - { - args: []string{"logging", "digitalocean", "list", "--service-id", "123", "--version", "1", "--verbose"}, - api: mock.API{ListDigitalOceansFn: listDigitalOceansOK}, - wantOutput: listDigitalOceansVerboseOutput, - }, - { - args: []string{"logging", "digitalocean", "list", "--service-id", "123", "--version", "1", "-v"}, - api: mock.API{ListDigitalOceansFn: listDigitalOceansOK}, - wantOutput: listDigitalOceansVerboseOutput, - }, - { - args: []string{"logging", "digitalocean", "--verbose", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListDigitalOceansFn: listDigitalOceansOK}, - wantOutput: listDigitalOceansVerboseOutput, - }, - { - args: []string{"logging", "-v", "digitalocean", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListDigitalOceansFn: listDigitalOceansOK}, - wantOutput: listDigitalOceansVerboseOutput, - }, - { - args: []string{"logging", "digitalocean", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListDigitalOceansFn: listDigitalOceansError}, - wantError: errTest.Error(), - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertString(t, testcase.wantOutput, out.String()) - }) - } -} - -func TestDigitalOceanDescribe(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "digitalocean", "describe", "--service-id", "123", "--version", "1"}, - wantError: "error parsing arguments: required flag --name not provided", - }, - { - args: []string{"logging", "digitalocean", "describe", "--service-id", "123", "--version", "1", "--name", "logs"}, - api: mock.API{GetDigitalOceanFn: getDigitalOceanError}, - wantError: errTest.Error(), - }, - { - args: []string{"logging", "digitalocean", "describe", "--service-id", "123", "--version", "1", "--name", "logs"}, - api: mock.API{GetDigitalOceanFn: getDigitalOceanOK}, - wantOutput: describeDigitalOceanOutput, - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertString(t, testcase.wantOutput, out.String()) - }) - } -} - -func TestDigitalOceanUpdate(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "digitalocean", "update", "--service-id", "123", "--version", "1", "--new-name", "log"}, - wantError: "error parsing arguments: required flag --name not provided", - }, - { - args: []string{"logging", "digitalocean", "update", "--service-id", "123", "--version", "1", "--name", "logs", "--new-name", "log"}, - api: mock.API{UpdateDigitalOceanFn: updateDigitalOceanError}, - wantError: errTest.Error(), - }, - { - args: []string{"logging", "digitalocean", "update", "--service-id", "123", "--version", "1", "--name", "logs", "--new-name", "log"}, - api: mock.API{UpdateDigitalOceanFn: updateDigitalOceanOK}, - wantOutput: "Updated DigitalOcean Spaces logging endpoint log (service 123 version 1)", - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertStringContains(t, out.String(), testcase.wantOutput) - }) - } -} - -func TestDigitalOceanDelete(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "digitalocean", "delete", "--service-id", "123", "--version", "1"}, - wantError: "error parsing arguments: required flag --name not provided", - }, - { - args: []string{"logging", "digitalocean", "delete", "--service-id", "123", "--version", "1", "--name", "logs"}, - api: mock.API{DeleteDigitalOceanFn: deleteDigitalOceanError}, - wantError: errTest.Error(), - }, - { - args: []string{"logging", "digitalocean", "delete", "--service-id", "123", "--version", "1", "--name", "logs"}, - api: mock.API{DeleteDigitalOceanFn: deleteDigitalOceanOK}, - wantOutput: "Deleted DigitalOcean Spaces logging endpoint logs (service 123 version 1)", - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertStringContains(t, out.String(), testcase.wantOutput) - }) - } -} - -var errTest = errors.New("fixture error") - -func createDigitalOceanOK(i *fastly.CreateDigitalOceanInput) (*fastly.DigitalOcean, error) { - s := fastly.DigitalOcean{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - } - - if i.Name != "" { - s.Name = i.Name - } - - return &s, nil -} - -func createDigitalOceanError(i *fastly.CreateDigitalOceanInput) (*fastly.DigitalOcean, error) { - return nil, errTest -} - -func listDigitalOceansOK(i *fastly.ListDigitalOceansInput) ([]*fastly.DigitalOcean, error) { - return []*fastly.DigitalOcean{ - { - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "logs", - BucketName: "my-logs", - Domain: "https://digitalocean.us-east-1.amazonaws.com", - AccessKey: "1234", - SecretKey: "-----BEGIN RSA PRIVATE KEY-----MIIEogIBAAKCA", - Path: "logs/", - Period: 3600, - GzipLevel: 9, - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - ResponseCondition: "Prevent default logging", - MessageType: "classic", - TimestampFormat: "%Y-%m-%dT%H:%M:%S.000", - Placement: "none", - PublicKey: pgpPublicKey(), - }, - { - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "analytics", - BucketName: "analytics", - AccessKey: "1234", - SecretKey: "-----BEGIN RSA PRIVATE KEY-----MIIEogIBAAKCA", - Domain: "https://digitalocean.us-east-2.amazonaws.com", - Path: "logs/", - Period: 86400, - GzipLevel: 9, - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - MessageType: "classic", - ResponseCondition: "Prevent default logging", - TimestampFormat: "%Y-%m-%dT%H:%M:%S.000", - Placement: "none", - PublicKey: pgpPublicKey(), - }, - }, nil -} - -func listDigitalOceansError(i *fastly.ListDigitalOceansInput) ([]*fastly.DigitalOcean, error) { - return nil, errTest -} - -var listDigitalOceansShortOutput = strings.TrimSpace(` -SERVICE VERSION NAME -123 1 logs -123 1 analytics -`) + "\n" - -var listDigitalOceansVerboseOutput = strings.TrimSpace(` -Fastly API token not provided -Fastly API endpoint: https://api.fastly.com -Service ID: 123 -Version: 1 - DigitalOcean 1/2 - Service ID: 123 - Version: 1 - Name: logs - Bucket: my-logs - Domain: https://digitalocean.us-east-1.amazonaws.com - Access key: 1234 - Secret key: -----BEGIN RSA PRIVATE KEY-----MIIEogIBAAKCA - Path: logs/ - Period: 3600 - GZip level: 9 - Format: %h %l %u %t "%r" %>s %b - Format version: 2 - Response condition: Prevent default logging - Message type: classic - Timestamp format: %Y-%m-%dT%H:%M:%S.000 - Placement: none - Public key: `+pgpPublicKey()+` - DigitalOcean 2/2 - Service ID: 123 - Version: 1 - Name: analytics - Bucket: analytics - Domain: https://digitalocean.us-east-2.amazonaws.com - Access key: 1234 - Secret key: -----BEGIN RSA PRIVATE KEY-----MIIEogIBAAKCA - Path: logs/ - Period: 86400 - GZip level: 9 - Format: %h %l %u %t "%r" %>s %b - Format version: 2 - Response condition: Prevent default logging - Message type: classic - Timestamp format: %Y-%m-%dT%H:%M:%S.000 - Placement: none - Public key: `+pgpPublicKey()+` -`) + "\n\n" - -func getDigitalOceanOK(i *fastly.GetDigitalOceanInput) (*fastly.DigitalOcean, error) { - return &fastly.DigitalOcean{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "logs", - BucketName: "my-logs", - Domain: "https://digitalocean.us-east-1.amazonaws.com", - AccessKey: "1234", - SecretKey: "-----BEGIN RSA PRIVATE KEY-----MIIEogIBAAKCA", - Path: "logs/", - Period: 3600, - GzipLevel: 9, - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - ResponseCondition: "Prevent default logging", - MessageType: "classic", - TimestampFormat: "%Y-%m-%dT%H:%M:%S.000", - Placement: "none", - PublicKey: pgpPublicKey(), - }, nil -} - -func getDigitalOceanError(i *fastly.GetDigitalOceanInput) (*fastly.DigitalOcean, error) { - return nil, errTest -} - -var describeDigitalOceanOutput = strings.TrimSpace(` -Service ID: 123 -Version: 1 -Name: logs -Bucket: my-logs -Domain: https://digitalocean.us-east-1.amazonaws.com -Access key: 1234 -Secret key: -----BEGIN RSA PRIVATE KEY-----MIIEogIBAAKCA -Path: logs/ -Period: 3600 -GZip level: 9 -Format: %h %l %u %t "%r" %>s %b -Format version: 2 -Response condition: Prevent default logging -Message type: classic -Timestamp format: %Y-%m-%dT%H:%M:%S.000 -Placement: none -Public key: `+pgpPublicKey()+` -`) + "\n" - -func updateDigitalOceanOK(i *fastly.UpdateDigitalOceanInput) (*fastly.DigitalOcean, error) { - return &fastly.DigitalOcean{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "log", - BucketName: "my-logs", - Domain: "https://digitalocean.us-east-1.amazonaws.com", - AccessKey: "1234", - SecretKey: "-----BEGIN RSA PRIVATE KEY-----MIIEogIBAAKCA", - Path: "logs/", - Period: 3600, - GzipLevel: 9, - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - ResponseCondition: "Prevent default logging", - MessageType: "classic", - TimestampFormat: "%Y-%m-%dT%H:%M:%S.000", - Placement: "none", - PublicKey: pgpPublicKey(), - }, nil -} - -func updateDigitalOceanError(i *fastly.UpdateDigitalOceanInput) (*fastly.DigitalOcean, error) { - return nil, errTest -} - -func deleteDigitalOceanOK(i *fastly.DeleteDigitalOceanInput) error { - return nil -} - -func deleteDigitalOceanError(i *fastly.DeleteDigitalOceanInput) error { - return errTest -} - -// pgpPublicKey returns a PEM encoded PGP public key suitable for testing. -func pgpPublicKey() string { - return strings.TrimSpace(`-----BEGIN PGP PUBLIC KEY BLOCK----- -mQENBFyUD8sBCACyFnB39AuuTygseek+eA4fo0cgwva6/FSjnWq7riouQee8GgQ/ -ibXTRyv4iVlwI12GswvMTIy7zNvs1R54i0qvsLr+IZ4GVGJqs6ZJnvQcqe3xPoR4 -8AnBfw90o32r/LuHf6QCJXi+AEu35koNlNAvLJ2B+KACaNB7N0EeWmqpV/1V2k9p -lDYk+th7LcCuaFNGqKS/PrMnnMqR6VDLCjHhNx4KR79b0Twm/2qp6an3hyNRu8Gn -dwxpf1/BUu3JWf+LqkN4Y3mbOmSUL3MaJNvyQguUzTfS0P0uGuBDHrJCVkMZCzDB -89ag55jCPHyGeHBTd02gHMWzsg3WMBWvCsrzABEBAAG0JXRlcnJhZm9ybSAodGVz -dCkgPHRlc3RAdGVycmFmb3JtLmNvbT6JAU4EEwEIADgWIQSHYyc6Kj9l6HzQsau6 -vFFc9jxV/wUCXJQPywIbAwULCQgHAgYVCgkICwIEFgIDAQIeAQIXgAAKCRC6vFFc -9jxV/815CAClb32OxV7wG01yF97TzlyTl8TnvjMtoG29Mw4nSyg+mjM3b8N7iXm9 -OLX59fbDAWtBSldSZE22RXd3CvlFOG/EnKBXSjBtEqfyxYSnyOPkMPBYWGL/ApkX -SvPYJ4LKdvipYToKFh3y9kk2gk1DcDBDyaaHvR+3rv1u3aoy7/s2EltAfDS3ZQIq -7/cWTLJml/lleeB/Y6rPj8xqeCYhE5ahw9gsV/Mdqatl24V9Tks30iijx0Hhw+Gx -kATUikMGr2GDVqoIRga5kXI7CzYff4rkc0Twn47fMHHHe/KY9M2yVnMHUXmAZwbG -M1cMI/NH1DjevCKdGBLcRJlhuLPKF/anuQENBFyUD8sBCADIpd7r7GuPd6n/Ikxe -u6h7umV6IIPoAm88xCYpTbSZiaK30Svh6Ywra9jfE2KlU9o6Y/art8ip0VJ3m07L -4RSfSpnzqgSwdjSq5hNour2Fo/BzYhK7yaz2AzVSbe33R0+RYhb4b/6N+bKbjwGF -ftCsqVFMH+PyvYkLbvxyQrHlA9woAZaNThI1ztO5rGSnGUR8xt84eup28WIFKg0K -UEGUcTzz+8QGAwAra+0ewPXo/AkO+8BvZjDidP417u6gpBHOJ9qYIcO9FxHeqFyu -YrjlrxowEgXn5wO8xuNz6Vu1vhHGDHGDsRbZF8pv1d5O+0F1G7ttZ2GRRgVBZPwi -kiyRABEBAAGJATYEGAEIACAWIQSHYyc6Kj9l6HzQsau6vFFc9jxV/wUCXJQPywIb -DAAKCRC6vFFc9jxV/9YOCACe8qmOSnKQpQfW+PqYOqo3dt7JyweTs3FkD6NT8Zml -dYy/vkstbTjPpX6aTvUZjkb46BVi7AOneVHpD5GBqvRsZ9iVgDYHaehmLCdKiG5L -3Tp90NN+QY5WDbsGmsyk6+6ZMYejb4qYfweQeduOj27aavCJdLkCYMoRKfcFYI8c -FaNmEfKKy/r1PO20NXEG6t9t05K/frHy6ZG8bCNYdpagfFVot47r9JaQqWlTNtIR -5+zkkSq/eG9BEtRij3a6cTdQbktdBzx2KBeI0PYc1vlZR0LpuFKZqY9vlE6vTGLR -wMfrTEOvx0NxUM3rpaCgEmuWbB1G1Hu371oyr4srrr+N -=28dr ------END PGP PUBLIC KEY BLOCK----- -`) -} diff --git a/pkg/logging/digitalocean/digitalocean_test.go b/pkg/logging/digitalocean/digitalocean_test.go deleted file mode 100644 index cb0ebb9ea..000000000 --- a/pkg/logging/digitalocean/digitalocean_test.go +++ /dev/null @@ -1,269 +0,0 @@ -package digitalocean - -import ( - "strings" - "testing" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/mock" - "github.com/fastly/cli/pkg/testutil" - "github.com/fastly/go-fastly/v3/fastly" -) - -func TestCreateDigitalOceanInput(t *testing.T) { - for _, testcase := range []struct { - name string - cmd *CreateCommand - want *fastly.CreateDigitalOceanInput - wantError string - }{ - { - name: "required values set flag serviceID", - cmd: createCommandRequired(), - want: &fastly.CreateDigitalOceanInput{ - ServiceID: "123", - ServiceVersion: 2, - Name: "log", - BucketName: "bucket", - AccessKey: "access", - SecretKey: "secret", - }, - }, - { - name: "all values set flag serviceID", - cmd: createCommandAll(), - want: &fastly.CreateDigitalOceanInput{ - ServiceID: "123", - ServiceVersion: 2, - Name: "log", - BucketName: "bucket", - Domain: "nyc3.digitaloceanspaces.com", - AccessKey: "access", - SecretKey: "secret", - Path: "/log", - Period: 3600, - GzipLevel: 0, - Format: `%h %l %u %t "%r" %>s %b`, - MessageType: "classic", - FormatVersion: 2, - ResponseCondition: "Prevent default logging", - TimestampFormat: "%Y-%m-%dT%H:%M:%S.000", - Placement: "none", - PublicKey: pgpPublicKey(), - CompressionCodec: "zstd", - }, - }, - { - name: "error missing serviceID", - cmd: createCommandMissingServiceID(), - want: nil, - wantError: errors.ErrNoServiceID.Error(), - }, - } { - t.Run(testcase.name, func(t *testing.T) { - have, err := testcase.cmd.createInput() - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertEqual(t, testcase.want, have) - }) - } -} - -func TestUpdateDigitalOceanInput(t *testing.T) { - for _, testcase := range []struct { - name string - cmd *UpdateCommand - api mock.API - want *fastly.UpdateDigitalOceanInput - wantError string - }{ - { - name: "all values set flag serviceID", - cmd: updateCommandAll(), - api: mock.API{GetDigitalOceanFn: getDigitalOceanOK}, - want: &fastly.UpdateDigitalOceanInput{ - ServiceID: "123", - ServiceVersion: 2, - Name: "log", - NewName: fastly.String("new1"), - BucketName: fastly.String("new2"), - Domain: fastly.String("new3"), - AccessKey: fastly.String("new4"), - SecretKey: fastly.String("new5"), - Path: fastly.String("new6"), - Period: fastly.Uint(3601), - GzipLevel: fastly.Uint(0), - Format: fastly.String("new7"), - FormatVersion: fastly.Uint(3), - ResponseCondition: fastly.String("new8"), - MessageType: fastly.String("new9"), - TimestampFormat: fastly.String("new10"), - Placement: fastly.String("new11"), - PublicKey: fastly.String("new12"), - CompressionCodec: fastly.String("new13"), - }, - }, - { - name: "no updates", - cmd: updateCommandNoUpdates(), - api: mock.API{GetDigitalOceanFn: getDigitalOceanOK}, - want: &fastly.UpdateDigitalOceanInput{ - ServiceID: "123", - ServiceVersion: 2, - Name: "log", - }, - }, - { - name: "error missing serviceID", - cmd: updateCommandMissingServiceID(), - want: nil, - wantError: errors.ErrNoServiceID.Error(), - }, - } { - t.Run(testcase.name, func(t *testing.T) { - testcase.cmd.Base.Globals.Client = testcase.api - - have, err := testcase.cmd.createInput() - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertEqual(t, testcase.want, have) - }) - } -} - -func createCommandRequired() *CreateCommand { - return &CreateCommand{ - manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, - EndpointName: "log", - Version: 2, - BucketName: "bucket", - AccessKey: "access", - SecretKey: "secret", - } -} - -func createCommandAll() *CreateCommand { - return &CreateCommand{ - manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, - EndpointName: "log", - Version: 2, - BucketName: "bucket", - AccessKey: "access", - SecretKey: "secret", - Domain: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "nyc3.digitaloceanspaces.com"}, - Path: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "/log"}, - Period: common.OptionalUint{Optional: common.Optional{WasSet: true}, Value: 3600}, - Format: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: `%h %l %u %t "%r" %>s %b`}, - FormatVersion: common.OptionalUint{Optional: common.Optional{WasSet: true}, Value: 2}, - ResponseCondition: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "Prevent default logging"}, - TimestampFormat: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "%Y-%m-%dT%H:%M:%S.000"}, - Placement: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "none"}, - MessageType: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "classic"}, - PublicKey: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: pgpPublicKey()}, - CompressionCodec: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "zstd"}, - } -} - -func createCommandMissingServiceID() *CreateCommand { - res := createCommandAll() - res.manifest = manifest.Data{} - return res -} - -func updateCommandNoUpdates() *UpdateCommand { - return &UpdateCommand{ - Base: common.Base{Globals: &config.Data{Client: nil}}, - manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, - EndpointName: "log", - Version: 2, - } -} - -func updateCommandAll() *UpdateCommand { - return &UpdateCommand{ - Base: common.Base{Globals: &config.Data{Client: nil}}, - manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, - EndpointName: "log", - Version: 2, - NewName: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new1"}, - BucketName: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new2"}, - Domain: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new3"}, - AccessKey: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new4"}, - SecretKey: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new5"}, - Path: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new6"}, - Period: common.OptionalUint{Optional: common.Optional{WasSet: true}, Value: 3601}, - GzipLevel: common.OptionalUint{Optional: common.Optional{WasSet: true}, Value: 0}, - Format: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new7"}, - FormatVersion: common.OptionalUint{Optional: common.Optional{WasSet: true}, Value: 3}, - ResponseCondition: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new8"}, - MessageType: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new9"}, - TimestampFormat: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new10"}, - Placement: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new11"}, - PublicKey: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new12"}, - CompressionCodec: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new13"}, - } -} - -func updateCommandMissingServiceID() *UpdateCommand { - res := updateCommandAll() - res.manifest = manifest.Data{} - return res -} - -func getDigitalOceanOK(i *fastly.GetDigitalOceanInput) (*fastly.DigitalOcean, error) { - return &fastly.DigitalOcean{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "logs", - BucketName: "bucket", - Domain: "nyc3.digitaloceanspaces.com", - AccessKey: "access", - SecretKey: "secret", - Path: "/log", - Period: 3600, - GzipLevel: 0, - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - ResponseCondition: "Prevent default logging", - MessageType: "classic", - TimestampFormat: "%Y-%m-%dT%H:%M:%S.000", - Placement: "none", - PublicKey: pgpPublicKey(), - CompressionCodec: "zstd", - }, nil -} - -// pgpPublicKey returns a PEM encoded PGP public key suitable for testing. -func pgpPublicKey() string { - return strings.TrimSpace(`-----BEGIN PGP PUBLIC KEY BLOCK----- -mQENBFyUD8sBCACyFnB39AuuTygseek+eA4fo0cgwva6/FSjnWq7riouQee8GgQ/ -ibXTRyv4iVlwI12GswvMTIy7zNvs1R54i0qvsLr+IZ4GVGJqs6ZJnvQcqe3xPoR4 -8AnBfw90o32r/LuHf6QCJXi+AEu35koNlNAvLJ2B+KACaNB7N0EeWmqpV/1V2k9p -lDYk+th7LcCuaFNGqKS/PrMnnMqR6VDLCjHhNx4KR79b0Twm/2qp6an3hyNRu8Gn -dwxpf1/BUu3JWf+LqkN4Y3mbOmSUL3MaJNvyQguUzTfS0P0uGuBDHrJCVkMZCzDB -89ag55jCPHyGeHBTd02gHMWzsg3WMBWvCsrzABEBAAG0JXRlcnJhZm9ybSAodGVz -dCkgPHRlc3RAdGVycmFmb3JtLmNvbT6JAU4EEwEIADgWIQSHYyc6Kj9l6HzQsau6 -vFFc9jxV/wUCXJQPywIbAwULCQgHAgYVCgkICwIEFgIDAQIeAQIXgAAKCRC6vFFc -9jxV/815CAClb32OxV7wG01yF97TzlyTl8TnvjMtoG29Mw4nSyg+mjM3b8N7iXm9 -OLX59fbDAWtBSldSZE22RXd3CvlFOG/EnKBXSjBtEqfyxYSnyOPkMPBYWGL/ApkX -SvPYJ4LKdvipYToKFh3y9kk2gk1DcDBDyaaHvR+3rv1u3aoy7/s2EltAfDS3ZQIq -7/cWTLJml/lleeB/Y6rPj8xqeCYhE5ahw9gsV/Mdqatl24V9Tks30iijx0Hhw+Gx -kATUikMGr2GDVqoIRga5kXI7CzYff4rkc0Twn47fMHHHe/KY9M2yVnMHUXmAZwbG -M1cMI/NH1DjevCKdGBLcRJlhuLPKF/anuQENBFyUD8sBCADIpd7r7GuPd6n/Ikxe -u6h7umV6IIPoAm88xCYpTbSZiaK30Svh6Ywra9jfE2KlU9o6Y/art8ip0VJ3m07L -4RSfSpnzqgSwdjSq5hNour2Fo/BzYhK7yaz2AzVSbe33R0+RYhb4b/6N+bKbjwGF -ftCsqVFMH+PyvYkLbvxyQrHlA9woAZaNThI1ztO5rGSnGUR8xt84eup28WIFKg0K -UEGUcTzz+8QGAwAra+0ewPXo/AkO+8BvZjDidP417u6gpBHOJ9qYIcO9FxHeqFyu -YrjlrxowEgXn5wO8xuNz6Vu1vhHGDHGDsRbZF8pv1d5O+0F1G7ttZ2GRRgVBZPwi -kiyRABEBAAGJATYEGAEIACAWIQSHYyc6Kj9l6HzQsau6vFFc9jxV/wUCXJQPywIb -DAAKCRC6vFFc9jxV/9YOCACe8qmOSnKQpQfW+PqYOqo3dt7JyweTs3FkD6NT8Zml -dYy/vkstbTjPpX6aTvUZjkb46BVi7AOneVHpD5GBqvRsZ9iVgDYHaehmLCdKiG5L -3Tp90NN+QY5WDbsGmsyk6+6ZMYejb4qYfweQeduOj27aavCJdLkCYMoRKfcFYI8c -FaNmEfKKy/r1PO20NXEG6t9t05K/frHy6ZG8bCNYdpagfFVot47r9JaQqWlTNtIR -5+zkkSq/eG9BEtRij3a6cTdQbktdBzx2KBeI0PYc1vlZR0LpuFKZqY9vlE6vTGLR -wMfrTEOvx0NxUM3rpaCgEmuWbB1G1Hu371oyr4srrr+N -=28dr ------END PGP PUBLIC KEY BLOCK----- -`) -} diff --git a/pkg/logging/digitalocean/list.go b/pkg/logging/digitalocean/list.go deleted file mode 100644 index 00fd8cb75..000000000 --- a/pkg/logging/digitalocean/list.go +++ /dev/null @@ -1,82 +0,0 @@ -package digitalocean - -import ( - "fmt" - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// ListCommand calls the Fastly API to list DigitalOcean Spaces logging endpoints. -type ListCommand struct { - common.Base - manifest manifest.Data - Input fastly.ListDigitalOceansInput -} - -// NewListCommand returns a usable command registered under the parent. -func NewListCommand(parent common.Registerer, globals *config.Data) *ListCommand { - var c ListCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("list", "List DigitalOcean Spaces logging endpoints on a Fastly service version") - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Input.ServiceVersion) - return &c -} - -// Exec invokes the application logic for the command. -func (c *ListCommand) Exec(in io.Reader, out io.Writer) error { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - c.Input.ServiceID = serviceID - - digitaloceans, err := c.Globals.Client.ListDigitalOceans(&c.Input) - if err != nil { - return err - } - - if !c.Globals.Verbose() { - tw := text.NewTable(out) - tw.AddHeader("SERVICE", "VERSION", "NAME") - for _, digitalocean := range digitaloceans { - tw.AddLine(digitalocean.ServiceID, digitalocean.ServiceVersion, digitalocean.Name) - } - tw.Print() - return nil - } - - fmt.Fprintf(out, "Service ID: %s\n", c.Input.ServiceID) - fmt.Fprintf(out, "Version: %d\n", c.Input.ServiceVersion) - for i, digitalocean := range digitaloceans { - fmt.Fprintf(out, "\tDigitalOcean %d/%d\n", i+1, len(digitaloceans)) - fmt.Fprintf(out, "\t\tService ID: %s\n", digitalocean.ServiceID) - fmt.Fprintf(out, "\t\tVersion: %d\n", digitalocean.ServiceVersion) - fmt.Fprintf(out, "\t\tName: %s\n", digitalocean.Name) - fmt.Fprintf(out, "\t\tBucket: %s\n", digitalocean.BucketName) - fmt.Fprintf(out, "\t\tDomain: %s\n", digitalocean.Domain) - fmt.Fprintf(out, "\t\tAccess key: %s\n", digitalocean.AccessKey) - fmt.Fprintf(out, "\t\tSecret key: %s\n", digitalocean.SecretKey) - fmt.Fprintf(out, "\t\tPath: %s\n", digitalocean.Path) - fmt.Fprintf(out, "\t\tPeriod: %d\n", digitalocean.Period) - fmt.Fprintf(out, "\t\tGZip level: %d\n", digitalocean.GzipLevel) - fmt.Fprintf(out, "\t\tFormat: %s\n", digitalocean.Format) - fmt.Fprintf(out, "\t\tFormat version: %d\n", digitalocean.FormatVersion) - fmt.Fprintf(out, "\t\tResponse condition: %s\n", digitalocean.ResponseCondition) - fmt.Fprintf(out, "\t\tMessage type: %s\n", digitalocean.MessageType) - fmt.Fprintf(out, "\t\tTimestamp format: %s\n", digitalocean.TimestampFormat) - fmt.Fprintf(out, "\t\tPlacement: %s\n", digitalocean.Placement) - fmt.Fprintf(out, "\t\tPublic key: %s\n", digitalocean.PublicKey) - } - fmt.Fprintln(out) - - return nil -} diff --git a/pkg/logging/digitalocean/root.go b/pkg/logging/digitalocean/root.go deleted file mode 100644 index 4c48ddbf7..000000000 --- a/pkg/logging/digitalocean/root.go +++ /dev/null @@ -1,28 +0,0 @@ -package digitalocean - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/config" -) - -// RootCommand is the parent command for all subcommands in this package. -// It should be installed under the primary root command. -type RootCommand struct { - common.Base - // no flags -} - -// NewRootCommand returns a new command registered in the parent. -func NewRootCommand(parent common.Registerer, globals *config.Data) *RootCommand { - var c RootCommand - c.Globals = globals - c.CmdClause = parent.Command("digitalocean", "Manipulate Fastly service version DigitalOcean Spaces logging endpoints") - return &c -} - -// Exec implements the command interface. -func (c *RootCommand) Exec(in io.Reader, out io.Writer) error { - panic("unreachable") -} diff --git a/pkg/logging/digitalocean/update.go b/pkg/logging/digitalocean/update.go deleted file mode 100644 index 2b28b4f2e..000000000 --- a/pkg/logging/digitalocean/update.go +++ /dev/null @@ -1,170 +0,0 @@ -package digitalocean - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// UpdateCommand calls the Fastly API to update a DigitalOcean Spaces logging endpoint. -type UpdateCommand struct { - common.Base - manifest manifest.Data - - //required - EndpointName string - Version int - - // optional - NewName common.OptionalString - BucketName common.OptionalString - Domain common.OptionalString - AccessKey common.OptionalString - SecretKey common.OptionalString - Path common.OptionalString - Period common.OptionalUint - GzipLevel common.OptionalUint - Format common.OptionalString - FormatVersion common.OptionalUint - ResponseCondition common.OptionalString - MessageType common.OptionalString - TimestampFormat common.OptionalString - Placement common.OptionalString - PublicKey common.OptionalString - CompressionCodec common.OptionalString -} - -// NewUpdateCommand returns a usable command registered under the parent. -func NewUpdateCommand(parent common.Registerer, globals *config.Data) *UpdateCommand { - var c UpdateCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - - c.CmdClause = parent.Command("update", "Update a DigitalOcean Spaces logging endpoint on a Fastly service version") - - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Version) - c.CmdClause.Flag("name", "The name of the DigitalOcean Spaces logging object").Short('n').Required().StringVar(&c.EndpointName) - - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("new-name", "New name of the DigitalOcean Spaces logging object").Action(c.NewName.Set).StringVar(&c.NewName.Value) - c.CmdClause.Flag("bucket", "The name of the DigitalOcean Space").Action(c.BucketName.Set).StringVar(&c.BucketName.Value) - c.CmdClause.Flag("domain", "The domain of the DigitalOcean Spaces endpoint (default 'nyc3.digitaloceanspaces.com')").Action(c.Domain.Set).StringVar(&c.Domain.Value) - c.CmdClause.Flag("access-key", "Your DigitalOcean Spaces account access key").Action(c.AccessKey.Set).StringVar(&c.AccessKey.Value) - c.CmdClause.Flag("secret-key", "Your DigitalOcean Spaces account secret key").Action(c.SecretKey.Set).StringVar(&c.SecretKey.Value) - c.CmdClause.Flag("path", "The path to upload logs to").Action(c.Path.Set).StringVar(&c.Path.Value) - c.CmdClause.Flag("period", "How frequently log files are finalized so they can be available for reading (in seconds, default 3600)").Action(c.Period.Set).UintVar(&c.Period.Value) - c.CmdClause.Flag("gzip-level", "What level of GZIP encoding to have when dumping logs (default 0, no compression)").Action(c.GzipLevel.Set).UintVar(&c.GzipLevel.Value) - c.CmdClause.Flag("format", "Apache style log formatting").Action(c.Format.Set).StringVar(&c.Format.Value) - c.CmdClause.Flag("format-version", "The version of the custom logging format used for the configured endpoint. Can be either 2 (default) or 1").Action(c.FormatVersion.Set).UintVar(&c.FormatVersion.Value) - c.CmdClause.Flag("response-condition", "The name of an existing condition in the configured endpoint, or leave blank to always execute").Action(c.ResponseCondition.Set).StringVar(&c.ResponseCondition.Value) - c.CmdClause.Flag("message-type", "How the message should be formatted. One of: classic (default), loggly, logplex or blank").Action(c.MessageType.Set).StringVar(&c.MessageType.Value) - c.CmdClause.Flag("timestamp-format", `strftime specified timestamp formatting (default "%Y-%m-%dT%H:%M:%S.000")`).Action(c.TimestampFormat.Set).StringVar(&c.TimestampFormat.Value) - c.CmdClause.Flag("placement", "Where in the generated VCL the logging call should be placed, overriding any format_version default. Can be none or waf_debug").Action(c.Placement.Set).StringVar(&c.Placement.Value) - c.CmdClause.Flag("public-key", "A PGP public key that Fastly will use to encrypt your log files before writing them to disk").Action(c.PublicKey.Set).StringVar(&c.PublicKey.Value) - c.CmdClause.Flag("compression-codec", `The codec used for compression of your logs. Valid values are zstd, snappy, and gzip. If the specified codec is "gzip", gzip_level will default to 3. To specify a different level, leave compression_codec blank and explicitly set the level using gzip_level. Specifying both compression_codec and gzip_level in the same API request will result in an error.`).Action(c.CompressionCodec.Set).StringVar(&c.CompressionCodec.Value) - - return &c -} - -// createInput transforms values parsed from CLI flags into an object to be used by the API client library. -func (c *UpdateCommand) createInput() (*fastly.UpdateDigitalOceanInput, error) { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return nil, errors.ErrNoServiceID - } - - input := fastly.UpdateDigitalOceanInput{ - ServiceID: serviceID, - ServiceVersion: c.Version, - Name: c.EndpointName, - } - - // Set new values if set by user. - if c.NewName.WasSet { - input.NewName = fastly.String(c.NewName.Value) - } - - if c.BucketName.WasSet { - input.BucketName = fastly.String(c.BucketName.Value) - } - - if c.Domain.WasSet { - input.Domain = fastly.String(c.Domain.Value) - } - - if c.AccessKey.WasSet { - input.AccessKey = fastly.String(c.AccessKey.Value) - } - - if c.SecretKey.WasSet { - input.SecretKey = fastly.String(c.SecretKey.Value) - } - - if c.Path.WasSet { - input.Path = fastly.String(c.Path.Value) - } - - if c.Period.WasSet { - input.Period = fastly.Uint(c.Period.Value) - } - - if c.GzipLevel.WasSet { - input.GzipLevel = fastly.Uint(c.GzipLevel.Value) - } - - if c.Format.WasSet { - input.Format = fastly.String(c.Format.Value) - } - - if c.FormatVersion.WasSet { - input.FormatVersion = fastly.Uint(c.FormatVersion.Value) - } - - if c.ResponseCondition.WasSet { - input.ResponseCondition = fastly.String(c.ResponseCondition.Value) - } - - if c.MessageType.WasSet { - input.MessageType = fastly.String(c.MessageType.Value) - } - - if c.TimestampFormat.WasSet { - input.TimestampFormat = fastly.String(c.TimestampFormat.Value) - } - - if c.Placement.WasSet { - input.Placement = fastly.String(c.Placement.Value) - } - - if c.PublicKey.WasSet { - input.PublicKey = fastly.String(c.PublicKey.Value) - } - - if c.CompressionCodec.WasSet { - input.CompressionCodec = fastly.String(c.CompressionCodec.Value) - } - - return &input, nil -} - -// Exec invokes the application logic for the command. -func (c *UpdateCommand) Exec(in io.Reader, out io.Writer) error { - input, err := c.createInput() - if err != nil { - return err - } - - digitalocean, err := c.Globals.Client.UpdateDigitalOcean(input) - if err != nil { - return err - } - - text.Success(out, "Updated DigitalOcean Spaces logging endpoint %s (service %s version %d)", digitalocean.Name, digitalocean.ServiceID, digitalocean.ServiceVersion) - return nil -} diff --git a/pkg/logging/elasticsearch/create.go b/pkg/logging/elasticsearch/create.go deleted file mode 100644 index 9d6529a5f..000000000 --- a/pkg/logging/elasticsearch/create.go +++ /dev/null @@ -1,154 +0,0 @@ -package elasticsearch - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// CreateCommand calls the Fastly API to create an Elasticsearch logging endpoint. -type CreateCommand struct { - common.Base - manifest manifest.Data - - // required - EndpointName string // Can't shadow common.Base method Name(). - Version int - Index string - URL string - - // optional - Pipeline common.OptionalString - RequestMaxEntries common.OptionalUint - RequestMaxBytes common.OptionalUint - User common.OptionalString - Password common.OptionalString - TLSCACert common.OptionalString - TLSClientCert common.OptionalString - TLSClientKey common.OptionalString - TLSHostname common.OptionalString - Format common.OptionalString - FormatVersion common.OptionalUint - Placement common.OptionalString - ResponseCondition common.OptionalString -} - -// NewCreateCommand returns a usable command registered under the parent. -func NewCreateCommand(parent common.Registerer, globals *config.Data) *CreateCommand { - var c CreateCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("create", "Create an Elasticsearch logging endpoint on a Fastly service version").Alias("add") - - c.CmdClause.Flag("name", "The name of the Elasticsearch logging object. Used as a primary key for API access").Short('n').Required().StringVar(&c.EndpointName) - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Version) - c.CmdClause.Flag("index", `The name of the Elasticsearch index to send documents (logs) to. The index must follow the Elasticsearch index format rules (https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-create-index.html). We support strftime (http://man7.org/linux/man-pages/man3/strftime.3.html) interpolated variables inside braces prefixed with a pound symbol. For example, #{%F} will interpolate as YYYY-MM-DD with today's date`).Required().StringVar(&c.Index) - c.CmdClause.Flag("url", "The URL to stream logs to. Must use HTTPS.").Required().StringVar(&c.URL) - - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("pipeline", "The ID of the Elasticsearch ingest pipeline to apply pre-process transformations to before indexing. For example my_pipeline_id. Learn more about creating a pipeline in the Elasticsearch docs (https://www.elastic.co/guide/en/elasticsearch/reference/current/ingest.html)").Action(c.Password.Set).StringVar(&c.Pipeline.Value) - c.CmdClause.Flag("tls-ca-cert", "A secure certificate to authenticate the server with. Must be in PEM format").Action(c.TLSCACert.Set).StringVar(&c.TLSCACert.Value) - c.CmdClause.Flag("tls-client-cert", "The client certificate used to make authenticated requests. Must be in PEM format").Action(c.TLSClientCert.Set).StringVar(&c.TLSClientCert.Value) - c.CmdClause.Flag("tls-client-key", "The client private key used to make authenticated requests. Must be in PEM format").Action(c.TLSClientKey.Set).StringVar(&c.TLSClientKey.Value) - c.CmdClause.Flag("tls-hostname", "The hostname used to verify the server's certificate. It can either be the Common Name or a Subject Alternative Name (SAN)").Action(c.TLSHostname.Set).StringVar(&c.TLSHostname.Value) - c.CmdClause.Flag("format", "Apache style log formatting. Your log must produce valid JSON that Elasticsearch can ingest").Action(c.Format.Set).StringVar(&c.Format.Value) - c.CmdClause.Flag("format-version", "The version of the custom logging format used for the configured endpoint. Can be either 2 (default) or 1").Action(c.FormatVersion.Set).UintVar(&c.FormatVersion.Value) - c.CmdClause.Flag("placement", "Where in the generated VCL the logging call should be placed, overriding any format_version default. Can be none or waf_debug").Action(c.Placement.Set).StringVar(&c.Placement.Value) - c.CmdClause.Flag("response-condition", "The name of an existing condition in the configured endpoint, or leave blank to always execute").Action(c.ResponseCondition.Set).StringVar(&c.ResponseCondition.Value) - c.CmdClause.Flag("request-max-entries", "Maximum number of logs to append to a batch, if non-zero. Defaults to 0 for unbounded").Action(c.RequestMaxEntries.Set).UintVar(&c.RequestMaxEntries.Value) - c.CmdClause.Flag("request-max-bytes", "Maximum size of log batch, if non-zero. Defaults to 0 for unbounded").Action(c.RequestMaxBytes.Set).UintVar(&c.RequestMaxBytes.Value) - - return &c -} - -// createInput transforms values parsed from CLI flags into an object to be used by the API client library. -func (c *CreateCommand) createInput() (*fastly.CreateElasticsearchInput, error) { - var input fastly.CreateElasticsearchInput - - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return nil, errors.ErrNoServiceID - } - - input.ServiceID = serviceID - input.ServiceVersion = c.Version - input.Name = c.EndpointName - input.Index = c.Index - input.URL = c.URL - - if c.Pipeline.WasSet { - input.Pipeline = c.Pipeline.Value - } - - if c.RequestMaxEntries.WasSet { - input.RequestMaxEntries = c.RequestMaxEntries.Value - } - - if c.RequestMaxBytes.WasSet { - input.RequestMaxBytes = c.RequestMaxBytes.Value - } - - if c.User.WasSet { - input.User = c.User.Value - } - - if c.Password.WasSet { - input.Password = c.Password.Value - } - - if c.TLSCACert.WasSet { - input.TLSCACert = c.TLSCACert.Value - } - - if c.TLSClientCert.WasSet { - input.TLSClientCert = c.TLSClientCert.Value - } - - if c.TLSClientKey.WasSet { - input.TLSClientKey = c.TLSClientKey.Value - } - - if c.TLSHostname.WasSet { - input.TLSHostname = c.TLSHostname.Value - } - - if c.Format.WasSet { - input.Format = c.Format.Value - } - - if c.FormatVersion.WasSet { - input.FormatVersion = c.FormatVersion.Value - } - - if c.ResponseCondition.WasSet { - input.ResponseCondition = c.ResponseCondition.Value - } - - if c.Placement.WasSet { - input.Placement = c.Placement.Value - } - - return &input, nil -} - -// Exec invokes the application logic for the command. -func (c *CreateCommand) Exec(in io.Reader, out io.Writer) error { - input, err := c.createInput() - if err != nil { - return err - } - - d, err := c.Globals.Client.CreateElasticsearch(input) - if err != nil { - return err - } - - text.Success(out, "Created Elasticsearch logging endpoint %s (service %s version %d)", d.Name, d.ServiceID, d.ServiceVersion) - return nil -} diff --git a/pkg/logging/elasticsearch/delete.go b/pkg/logging/elasticsearch/delete.go deleted file mode 100644 index 7f32e9268..000000000 --- a/pkg/logging/elasticsearch/delete.go +++ /dev/null @@ -1,51 +0,0 @@ -package elasticsearch - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// DeleteCommand calls the Fastly API to delete an Elasticsearch logging endpoint. -type DeleteCommand struct { - common.Base - manifest manifest.Data - Input fastly.DeleteElasticsearchInput -} - -// NewDeleteCommand returns a usable command registered under the parent. -func NewDeleteCommand(parent common.Registerer, globals *config.Data) *DeleteCommand { - var c DeleteCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("delete", "Delete an Elasticsearch logging endpoint on a Fastly service version").Alias("remove") - - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Input.ServiceVersion) - c.CmdClause.Flag("name", "The name of the Elasticsearch logging object").Short('n').Required().StringVar(&c.Input.Name) - - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - - return &c -} - -// Exec invokes the application logic for the command. -func (c *DeleteCommand) Exec(in io.Reader, out io.Writer) error { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - c.Input.ServiceID = serviceID - - if err := c.Globals.Client.DeleteElasticsearch(&c.Input); err != nil { - return err - } - - text.Success(out, "Deleted Elasticsearch logging endpoint %s (service %s version %d)", c.Input.Name, c.Input.ServiceID, c.Input.ServiceVersion) - return nil -} diff --git a/pkg/logging/elasticsearch/describe.go b/pkg/logging/elasticsearch/describe.go deleted file mode 100644 index dc1d24fb1..000000000 --- a/pkg/logging/elasticsearch/describe.go +++ /dev/null @@ -1,65 +0,0 @@ -package elasticsearch - -import ( - "fmt" - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/go-fastly/v3/fastly" -) - -// DescribeCommand calls the Fastly API to describe an Elasticsearch logging endpoint. -type DescribeCommand struct { - common.Base - manifest manifest.Data - Input fastly.GetElasticsearchInput -} - -// NewDescribeCommand returns a usable command registered under the parent. -func NewDescribeCommand(parent common.Registerer, globals *config.Data) *DescribeCommand { - var c DescribeCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("describe", "Show detailed information about an Elasticsearch logging endpoint on a Fastly service version").Alias("get") - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Input.ServiceVersion) - c.CmdClause.Flag("name", "The name of the Elasticsearch logging object").Short('n').Required().StringVar(&c.Input.Name) - return &c -} - -// Exec invokes the application logic for the command. -func (c *DescribeCommand) Exec(in io.Reader, out io.Writer) error { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - c.Input.ServiceID = serviceID - - elasticsearch, err := c.Globals.Client.GetElasticsearch(&c.Input) - if err != nil { - return err - } - - fmt.Fprintf(out, "Service ID: %s\n", elasticsearch.ServiceID) - fmt.Fprintf(out, "Version: %d\n", elasticsearch.ServiceVersion) - fmt.Fprintf(out, "Name: %s\n", elasticsearch.Name) - fmt.Fprintf(out, "Index: %s\n", elasticsearch.Index) - fmt.Fprintf(out, "URL: %s\n", elasticsearch.URL) - fmt.Fprintf(out, "Pipeline: %s\n", elasticsearch.Pipeline) - fmt.Fprintf(out, "TLS CA certificate: %s\n", elasticsearch.TLSCACert) - fmt.Fprintf(out, "TLS client certificate: %s\n", elasticsearch.TLSClientCert) - fmt.Fprintf(out, "TLS client key: %s\n", elasticsearch.TLSClientKey) - fmt.Fprintf(out, "TLS hostname: %s\n", elasticsearch.TLSHostname) - fmt.Fprintf(out, "User: %s\n", elasticsearch.User) - fmt.Fprintf(out, "Password: %s\n", elasticsearch.Password) - fmt.Fprintf(out, "Format: %s\n", elasticsearch.Format) - fmt.Fprintf(out, "Format version: %d\n", elasticsearch.FormatVersion) - fmt.Fprintf(out, "Response condition: %s\n", elasticsearch.ResponseCondition) - fmt.Fprintf(out, "Placement: %s\n", elasticsearch.Placement) - - return nil -} diff --git a/pkg/logging/elasticsearch/elasticsearch_integration_test.go b/pkg/logging/elasticsearch/elasticsearch_integration_test.go deleted file mode 100644 index 22558236b..000000000 --- a/pkg/logging/elasticsearch/elasticsearch_integration_test.go +++ /dev/null @@ -1,448 +0,0 @@ -package elasticsearch_test - -import ( - "bytes" - "errors" - "io" - "net/http" - "strings" - "testing" - - "github.com/fastly/cli/pkg/app" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/mock" - "github.com/fastly/cli/pkg/testutil" - "github.com/fastly/cli/pkg/update" - "github.com/fastly/go-fastly/v3/fastly" -) - -func TestElasticsearchCreate(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "elasticsearch", "create", "--service-id", "123", "--version", "1", "--name", "log", "--index", "logs"}, - wantError: "error parsing arguments: required flag --url not provided", - }, - { - args: []string{"logging", "elasticsearch", "create", "--service-id", "123", "--version", "1", "--name", "log", "--url", "example.com"}, - wantError: "error parsing arguments: required flag --index not provided", - }, - { - args: []string{"logging", "elasticsearch", "create", "--service-id", "123", "--version", "1", "--name", "log", "--index", "logs", "--url", "example.com"}, - api: mock.API{CreateElasticsearchFn: createElasticsearchOK}, - wantOutput: "Created Elasticsearch logging endpoint log (service 123 version 1)", - }, - { - args: []string{"logging", "elasticsearch", "create", "--service-id", "123", "--version", "1", "--name", "log", "--index", "logs", "--url", "example.com"}, - api: mock.API{CreateElasticsearchFn: createElasticsearchError}, - wantError: errTest.Error(), - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertStringContains(t, out.String(), testcase.wantOutput) - }) - } -} - -func TestElasticsearchList(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "elasticsearch", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListElasticsearchFn: listElasticsearchsOK}, - wantOutput: listElasticsearchsShortOutput, - }, - { - args: []string{"logging", "elasticsearch", "list", "--service-id", "123", "--version", "1", "--verbose"}, - api: mock.API{ListElasticsearchFn: listElasticsearchsOK}, - wantOutput: listElasticsearchsVerboseOutput, - }, - { - args: []string{"logging", "elasticsearch", "list", "--service-id", "123", "--version", "1", "-v"}, - api: mock.API{ListElasticsearchFn: listElasticsearchsOK}, - wantOutput: listElasticsearchsVerboseOutput, - }, - { - args: []string{"logging", "elasticsearch", "--verbose", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListElasticsearchFn: listElasticsearchsOK}, - wantOutput: listElasticsearchsVerboseOutput, - }, - { - args: []string{"logging", "-v", "elasticsearch", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListElasticsearchFn: listElasticsearchsOK}, - wantOutput: listElasticsearchsVerboseOutput, - }, - { - args: []string{"logging", "elasticsearch", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListElasticsearchFn: listElasticsearchsError}, - wantError: errTest.Error(), - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertString(t, testcase.wantOutput, out.String()) - }) - } -} - -func TestElasticsearchDescribe(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "elasticsearch", "describe", "--service-id", "123", "--version", "1"}, - wantError: "error parsing arguments: required flag --name not provided", - }, - { - args: []string{"logging", "elasticsearch", "describe", "--service-id", "123", "--version", "1", "--name", "logs"}, - api: mock.API{GetElasticsearchFn: getElasticsearchError}, - wantError: errTest.Error(), - }, - { - args: []string{"logging", "elasticsearch", "describe", "--service-id", "123", "--version", "1", "--name", "logs"}, - api: mock.API{GetElasticsearchFn: getElasticsearchOK}, - wantOutput: describeElasticsearchOutput, - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertString(t, testcase.wantOutput, out.String()) - }) - } -} - -func TestElasticsearchUpdate(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "elasticsearch", "update", "--service-id", "123", "--version", "1", "--new-name", "log"}, - wantError: "error parsing arguments: required flag --name not provided", - }, - { - args: []string{"logging", "elasticsearch", "update", "--service-id", "123", "--version", "1", "--name", "logs", "--new-name", "log"}, - api: mock.API{UpdateElasticsearchFn: updateElasticsearchError}, - wantError: errTest.Error(), - }, - { - args: []string{"logging", "elasticsearch", "update", "--service-id", "123", "--version", "1", "--name", "logs", "--new-name", "log"}, - api: mock.API{UpdateElasticsearchFn: updateElasticsearchOK}, - wantOutput: "Updated Elasticsearch logging endpoint log (service 123 version 1)", - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertStringContains(t, out.String(), testcase.wantOutput) - }) - } -} - -func TestElasticsearchDelete(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "elasticsearch", "delete", "--service-id", "123", "--version", "1"}, - wantError: "error parsing arguments: required flag --name not provided", - }, - { - args: []string{"logging", "elasticsearch", "delete", "--service-id", "123", "--version", "1", "--name", "logs"}, - api: mock.API{DeleteElasticsearchFn: deleteElasticsearchError}, - wantError: errTest.Error(), - }, - { - args: []string{"logging", "elasticsearch", "delete", "--service-id", "123", "--version", "1", "--name", "logs"}, - api: mock.API{DeleteElasticsearchFn: deleteElasticsearchOK}, - wantOutput: "Deleted Elasticsearch logging endpoint logs (service 123 version 1)", - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertStringContains(t, out.String(), testcase.wantOutput) - }) - } -} - -var errTest = errors.New("fixture error") - -func createElasticsearchOK(i *fastly.CreateElasticsearchInput) (*fastly.Elasticsearch, error) { - return &fastly.Elasticsearch{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "log", - ResponseCondition: "Prevent default logging", - Format: `%h %l %u %t "%r" %>s %b`, - Index: "logs", - URL: "example.com", - Pipeline: "logs", - User: "user", - Password: "password", - RequestMaxEntries: 2, - RequestMaxBytes: 2, - Placement: "none", - TLSCACert: "-----BEGIN CERTIFICATE-----foo", - TLSHostname: "example.com", - TLSClientCert: "-----BEGIN CERTIFICATE-----bar", - TLSClientKey: "-----BEGIN PRIVATE KEY-----bar", - FormatVersion: 2, - }, nil -} - -func createElasticsearchError(i *fastly.CreateElasticsearchInput) (*fastly.Elasticsearch, error) { - return nil, errTest -} - -func listElasticsearchsOK(i *fastly.ListElasticsearchInput) ([]*fastly.Elasticsearch, error) { - return []*fastly.Elasticsearch{ - { - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "logs", - ResponseCondition: "Prevent default logging", - Format: `%h %l %u %t "%r" %>s %b`, - Index: "logs", - URL: "example.com", - Pipeline: "logs", - User: "user", - Password: "password", - RequestMaxEntries: 2, - RequestMaxBytes: 2, - Placement: "none", - TLSCACert: "-----BEGIN CERTIFICATE-----foo", - TLSHostname: "example.com", - TLSClientCert: "-----BEGIN CERTIFICATE-----bar", - TLSClientKey: "-----BEGIN PRIVATE KEY-----bar", - FormatVersion: 2, - }, - { - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "analytics", - Index: "analytics", - URL: "example.com", - Pipeline: "analytics", - User: "user", - Password: "password", - RequestMaxEntries: 2, - RequestMaxBytes: 2, - Placement: "none", - TLSCACert: "-----BEGIN CERTIFICATE-----foo", - TLSHostname: "example.com", - TLSClientCert: "-----BEGIN CERTIFICATE-----bar", - TLSClientKey: "-----BEGIN PRIVATE KEY-----bar", - ResponseCondition: "Prevent default logging", - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - }, - }, nil -} - -func listElasticsearchsError(i *fastly.ListElasticsearchInput) ([]*fastly.Elasticsearch, error) { - return nil, errTest -} - -var listElasticsearchsShortOutput = strings.TrimSpace(` -SERVICE VERSION NAME -123 1 logs -123 1 analytics -`) + "\n" - -var listElasticsearchsVerboseOutput = strings.TrimSpace(` -Fastly API token not provided -Fastly API endpoint: https://api.fastly.com -Service ID: 123 -Version: 1 - Elasticsearch 1/2 - Service ID: 123 - Version: 1 - Name: logs - Index: logs - URL: example.com - Pipeline: logs - TLS CA certificate: -----BEGIN CERTIFICATE-----foo - TLS client certificate: -----BEGIN CERTIFICATE-----bar - TLS client key: -----BEGIN PRIVATE KEY-----bar - TLS hostname: example.com - User: user - Password: password - Format: %h %l %u %t "%r" %>s %b - Format version: 2 - Response condition: Prevent default logging - Placement: none - Elasticsearch 2/2 - Service ID: 123 - Version: 1 - Name: analytics - Index: analytics - URL: example.com - Pipeline: analytics - TLS CA certificate: -----BEGIN CERTIFICATE-----foo - TLS client certificate: -----BEGIN CERTIFICATE-----bar - TLS client key: -----BEGIN PRIVATE KEY-----bar - TLS hostname: example.com - User: user - Password: password - Format: %h %l %u %t "%r" %>s %b - Format version: 2 - Response condition: Prevent default logging - Placement: none -`) + "\n\n" - -func getElasticsearchOK(i *fastly.GetElasticsearchInput) (*fastly.Elasticsearch, error) { - return &fastly.Elasticsearch{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "logs", - ResponseCondition: "Prevent default logging", - Format: `%h %l %u %t "%r" %>s %b`, - Index: "logs", - URL: "example.com", - Pipeline: "logs", - User: "user", - Password: "password", - RequestMaxEntries: 2, - RequestMaxBytes: 2, - Placement: "none", - TLSCACert: "-----BEGIN CERTIFICATE-----foo", - TLSHostname: "example.com", - TLSClientCert: "-----BEGIN CERTIFICATE-----bar", - TLSClientKey: "-----BEGIN PRIVATE KEY-----bar", - FormatVersion: 2, - }, nil -} - -func getElasticsearchError(i *fastly.GetElasticsearchInput) (*fastly.Elasticsearch, error) { - return nil, errTest -} - -var describeElasticsearchOutput = strings.TrimSpace(` -Service ID: 123 -Version: 1 -Name: logs -Index: logs -URL: example.com -Pipeline: logs -TLS CA certificate: -----BEGIN CERTIFICATE-----foo -TLS client certificate: -----BEGIN CERTIFICATE-----bar -TLS client key: -----BEGIN PRIVATE KEY-----bar -TLS hostname: example.com -User: user -Password: password -Format: %h %l %u %t "%r" %>s %b -Format version: 2 -Response condition: Prevent default logging -Placement: none -`) + "\n" - -func updateElasticsearchOK(i *fastly.UpdateElasticsearchInput) (*fastly.Elasticsearch, error) { - return &fastly.Elasticsearch{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "log", - ResponseCondition: "Prevent default logging", - Format: `%h %l %u %t "%r" %>s %b`, - Index: "logs", - URL: "example.com", - Pipeline: "logs", - User: "user", - Password: "password", - RequestMaxEntries: 2, - RequestMaxBytes: 2, - Placement: "none", - TLSCACert: "-----BEGIN CERTIFICATE-----foo", - TLSHostname: "example.com", - TLSClientCert: "-----BEGIN CERTIFICATE-----bar", - TLSClientKey: "-----BEGIN PRIVATE KEY-----bar", - FormatVersion: 2, - }, nil -} - -func updateElasticsearchError(i *fastly.UpdateElasticsearchInput) (*fastly.Elasticsearch, error) { - return nil, errTest -} - -func deleteElasticsearchOK(i *fastly.DeleteElasticsearchInput) error { - return nil -} - -func deleteElasticsearchError(i *fastly.DeleteElasticsearchInput) error { - return errTest -} diff --git a/pkg/logging/elasticsearch/elasticsearch_test.go b/pkg/logging/elasticsearch/elasticsearch_test.go deleted file mode 100644 index 78c2cfb8b..000000000 --- a/pkg/logging/elasticsearch/elasticsearch_test.go +++ /dev/null @@ -1,233 +0,0 @@ -package elasticsearch - -import ( - "testing" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/mock" - "github.com/fastly/cli/pkg/testutil" - "github.com/fastly/go-fastly/v3/fastly" -) - -func TestCreateElasticsearchInput(t *testing.T) { - for _, testcase := range []struct { - name string - cmd *CreateCommand - want *fastly.CreateElasticsearchInput - wantError string - }{ - { - name: "required values set flag serviceID", - cmd: createCommandRequired(), - want: &fastly.CreateElasticsearchInput{ - ServiceID: "123", - ServiceVersion: 2, - Name: "log", - Index: "logs", - URL: "example.com", - }, - }, - { - name: "all values set flag serviceID", - cmd: createCommandAll(), - want: &fastly.CreateElasticsearchInput{ - ServiceID: "123", - ServiceVersion: 2, - Name: "logs", - ResponseCondition: "Prevent default logging", - Format: `%h %l %u %t "%r" %>s %b`, - Index: "logs", - URL: "example.com", - Pipeline: "my_pipeline_id", - User: "user", - Password: "password", - RequestMaxEntries: 2, - RequestMaxBytes: 2, - Placement: "none", - TLSCACert: "-----BEGIN CERTIFICATE-----foo", - TLSHostname: "example.com", - TLSClientCert: "-----BEGIN CERTIFICATE-----bar", - TLSClientKey: "-----BEGIN PRIVATE KEY-----bar", - FormatVersion: 2, - }, - }, - { - name: "error missing serviceID", - cmd: createCommandMissingServiceID(), - want: nil, - wantError: errors.ErrNoServiceID.Error(), - }, - } { - t.Run(testcase.name, func(t *testing.T) { - have, err := testcase.cmd.createInput() - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertEqual(t, testcase.want, have) - }) - } -} - -func TestUpdateElasticsearchInput(t *testing.T) { - for _, testcase := range []struct { - name string - cmd *UpdateCommand - api mock.API - want *fastly.UpdateElasticsearchInput - wantError string - }{ - { - name: "all values set flag serviceID", - cmd: updateCommandAll(), - api: mock.API{GetElasticsearchFn: getElasticsearchOK}, - want: &fastly.UpdateElasticsearchInput{ - ServiceID: "123", - ServiceVersion: 2, - Name: "log", - NewName: fastly.String("new1"), - Index: fastly.String("new2"), - URL: fastly.String("new3"), - Pipeline: fastly.String("new4"), - User: fastly.String("new5"), - Password: fastly.String("new6"), - RequestMaxEntries: fastly.Uint(3), - RequestMaxBytes: fastly.Uint(3), - Placement: fastly.String("new7"), - Format: fastly.String("new8"), - FormatVersion: fastly.Uint(3), - ResponseCondition: fastly.String("new9"), - TLSCACert: fastly.String("new10"), - TLSClientCert: fastly.String("new11"), - TLSClientKey: fastly.String("new12"), - TLSHostname: fastly.String("new13"), - }, - }, - { - name: "no updates", - cmd: updateCommandNoUpdates(), - api: mock.API{GetElasticsearchFn: getElasticsearchOK}, - want: &fastly.UpdateElasticsearchInput{ - ServiceID: "123", - ServiceVersion: 2, - Name: "log", - }, - }, - { - name: "error missing serviceID", - cmd: updateCommandMissingServiceID(), - want: nil, - wantError: errors.ErrNoServiceID.Error(), - }, - } { - t.Run(testcase.name, func(t *testing.T) { - testcase.cmd.Base.Globals.Client = testcase.api - - have, err := testcase.cmd.createInput() - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertEqual(t, testcase.want, have) - }) - } -} - -func createCommandRequired() *CreateCommand { - return &CreateCommand{ - manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, - EndpointName: "log", - Version: 2, - Index: "logs", - URL: "example.com", - } -} - -func createCommandAll() *CreateCommand { - return &CreateCommand{ - manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, - EndpointName: "logs", - Version: 2, - Index: "logs", - URL: "example.com", - Pipeline: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "my_pipeline_id"}, - RequestMaxEntries: common.OptionalUint{Optional: common.Optional{WasSet: true}, Value: 2}, - RequestMaxBytes: common.OptionalUint{Optional: common.Optional{WasSet: true}, Value: 2}, - Format: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: `%h %l %u %t "%r" %>s %b`}, - FormatVersion: common.OptionalUint{Optional: common.Optional{WasSet: true}, Value: 2}, - ResponseCondition: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "Prevent default logging"}, - Placement: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "none"}, - User: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "user"}, - Password: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "password"}, - TLSCACert: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "-----BEGIN CERTIFICATE-----foo"}, - TLSHostname: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "example.com"}, - TLSClientCert: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "-----BEGIN CERTIFICATE-----bar"}, - TLSClientKey: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "-----BEGIN PRIVATE KEY-----bar"}, - } -} - -func createCommandMissingServiceID() *CreateCommand { - res := createCommandAll() - res.manifest = manifest.Data{} - return res -} - -func updateCommandNoUpdates() *UpdateCommand { - return &UpdateCommand{ - Base: common.Base{Globals: &config.Data{Client: nil}}, - manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, - EndpointName: "log", - Version: 2, - } -} - -func updateCommandAll() *UpdateCommand { - return &UpdateCommand{ - Base: common.Base{Globals: &config.Data{Client: nil}}, - manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, - EndpointName: "log", - Version: 2, - NewName: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new1"}, - Index: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new2"}, - URL: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new3"}, - Pipeline: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new4"}, - RequestMaxEntries: common.OptionalUint{Optional: common.Optional{WasSet: true}, Value: 3}, - RequestMaxBytes: common.OptionalUint{Optional: common.Optional{WasSet: true}, Value: 3}, - User: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new5"}, - Password: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new6"}, - Placement: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new7"}, - Format: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new8"}, - FormatVersion: common.OptionalUint{Optional: common.Optional{WasSet: true}, Value: 3}, - ResponseCondition: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new9"}, - TLSCACert: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new10"}, - TLSClientCert: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new11"}, - TLSClientKey: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new12"}, - TLSHostname: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new13"}, - } -} - -func updateCommandMissingServiceID() *UpdateCommand { - res := updateCommandAll() - res.manifest = manifest.Data{} - return res -} - -func getElasticsearchOK(i *fastly.GetElasticsearchInput) (*fastly.Elasticsearch, error) { - return &fastly.Elasticsearch{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "log", - ResponseCondition: "Prevent default logging", - Format: `%h %l %u %t "%r" %>s %b`, - Index: "logs", - URL: "example.com", - Pipeline: "my_pipeline_id", - User: "user", - Password: "password", - RequestMaxEntries: 2, - RequestMaxBytes: 2, - Placement: "none", - TLSCACert: "-----BEGIN CERTIFICATE-----foo", - TLSHostname: "example.com", - TLSClientCert: "-----BEGIN CERTIFICATE-----bar", - TLSClientKey: "-----BEGIN PRIVATE KEY-----bar", - FormatVersion: 2, - }, nil -} diff --git a/pkg/logging/elasticsearch/list.go b/pkg/logging/elasticsearch/list.go deleted file mode 100644 index a42c14110..000000000 --- a/pkg/logging/elasticsearch/list.go +++ /dev/null @@ -1,82 +0,0 @@ -package elasticsearch - -import ( - "fmt" - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// ListCommand calls the Fastly API to list Elasticsearch logging endpoints. -type ListCommand struct { - common.Base - manifest manifest.Data - Input fastly.ListElasticsearchInput -} - -// NewListCommand returns a usable command registered under the parent. -func NewListCommand(parent common.Registerer, globals *config.Data) *ListCommand { - var c ListCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("list", "List Elasticsearch endpoints on a Fastly service version") - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Input.ServiceVersion) - return &c -} - -// Exec invokes the application logic for the command. -func (c *ListCommand) Exec(in io.Reader, out io.Writer) error { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - c.Input.ServiceID = serviceID - - elasticsearchs, err := c.Globals.Client.ListElasticsearch(&c.Input) - if err != nil { - return err - } - - if !c.Globals.Verbose() { - tw := text.NewTable(out) - tw.AddHeader("SERVICE", "VERSION", "NAME") - for _, elasticsearch := range elasticsearchs { - tw.AddLine(elasticsearch.ServiceID, elasticsearch.ServiceVersion, elasticsearch.Name) - } - tw.Print() - return nil - } - - fmt.Fprintf(out, "Service ID: %s\n", c.Input.ServiceID) - fmt.Fprintf(out, "Version: %d\n", c.Input.ServiceVersion) - for i, elasticsearch := range elasticsearchs { - fmt.Fprintf(out, "\tElasticsearch %d/%d\n", i+1, len(elasticsearchs)) - fmt.Fprintf(out, "\t\tService ID: %s\n", elasticsearch.ServiceID) - fmt.Fprintf(out, "\t\tVersion: %d\n", elasticsearch.ServiceVersion) - fmt.Fprintf(out, "\t\tName: %s\n", elasticsearch.Name) - fmt.Fprintf(out, "\t\tIndex: %s\n", elasticsearch.Index) - fmt.Fprintf(out, "\t\tURL: %s\n", elasticsearch.URL) - fmt.Fprintf(out, "\t\tPipeline: %s\n", elasticsearch.Pipeline) - fmt.Fprintf(out, "\t\tTLS CA certificate: %s\n", elasticsearch.TLSCACert) - fmt.Fprintf(out, "\t\tTLS client certificate: %s\n", elasticsearch.TLSClientCert) - fmt.Fprintf(out, "\t\tTLS client key: %s\n", elasticsearch.TLSClientKey) - fmt.Fprintf(out, "\t\tTLS hostname: %s\n", elasticsearch.TLSHostname) - fmt.Fprintf(out, "\t\tUser: %s\n", elasticsearch.User) - fmt.Fprintf(out, "\t\tPassword: %s\n", elasticsearch.Password) - fmt.Fprintf(out, "\t\tFormat: %s\n", elasticsearch.Format) - fmt.Fprintf(out, "\t\tFormat version: %d\n", elasticsearch.FormatVersion) - fmt.Fprintf(out, "\t\tResponse condition: %s\n", elasticsearch.ResponseCondition) - fmt.Fprintf(out, "\t\tPlacement: %s\n", elasticsearch.Placement) - - } - fmt.Fprintln(out) - - return nil -} diff --git a/pkg/logging/elasticsearch/root.go b/pkg/logging/elasticsearch/root.go deleted file mode 100644 index 5e77335a9..000000000 --- a/pkg/logging/elasticsearch/root.go +++ /dev/null @@ -1,28 +0,0 @@ -package elasticsearch - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/config" -) - -// RootCommand is the parent command for all subcommands in this package. -// It should be installed under the primary root command. -type RootCommand struct { - common.Base - // no flags -} - -// NewRootCommand returns a new command registered in the parent. -func NewRootCommand(parent common.Registerer, globals *config.Data) *RootCommand { - var c RootCommand - c.Globals = globals - c.CmdClause = parent.Command("elasticsearch", "Manipulate Fastly service version Elasticsearch logging endpoints") - return &c -} - -// Exec implements the command interface. -func (c *RootCommand) Exec(in io.Reader, out io.Writer) error { - panic("unreachable") -} diff --git a/pkg/logging/elasticsearch/update.go b/pkg/logging/elasticsearch/update.go deleted file mode 100644 index d16e99688..000000000 --- a/pkg/logging/elasticsearch/update.go +++ /dev/null @@ -1,167 +0,0 @@ -package elasticsearch - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// UpdateCommand calls the Fastly API to update an Elasticsearch logging endpoint. -type UpdateCommand struct { - common.Base - manifest manifest.Data - - // required - EndpointName string // Can't shadow common.Base method Name(). - Version int - - // optional - NewName common.OptionalString - Index common.OptionalString - URL common.OptionalString - Pipeline common.OptionalString - RequestMaxEntries common.OptionalUint - RequestMaxBytes common.OptionalUint - User common.OptionalString - Password common.OptionalString - TLSCACert common.OptionalString - TLSClientCert common.OptionalString - TLSClientKey common.OptionalString - TLSHostname common.OptionalString - Format common.OptionalString - FormatVersion common.OptionalUint - Placement common.OptionalString - ResponseCondition common.OptionalString -} - -// NewUpdateCommand returns a usable command registered under the parent. -func NewUpdateCommand(parent common.Registerer, globals *config.Data) *UpdateCommand { - var c UpdateCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - - c.CmdClause = parent.Command("update", "Update an Elasticsearch logging endpoint on a Fastly service version") - - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Version) - c.CmdClause.Flag("name", "The name of the Elasticsearch logging object").Short('n').Required().StringVar(&c.EndpointName) - - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("new-name", "New name of the Elasticsearch logging object").Action(c.NewName.Set).StringVar(&c.NewName.Value) - c.CmdClause.Flag("index", `The name of the Elasticsearch index to send documents (logs) to. The index must follow the Elasticsearch index format rules (https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-create-index.html). We support strftime (http://man7.org/linux/man-pages/man3/strftime.3.html) interpolated variables inside braces prefixed with a pound symbol. For example, #{%F} will interpolate as YYYY-MM-DD with today's date`).Action(c.Index.Set).StringVar(&c.Index.Value) - c.CmdClause.Flag("url", "The URL to stream logs to. Must use HTTPS.").Action(c.URL.Set).StringVar(&c.URL.Value) - c.CmdClause.Flag("pipeline", "The ID of the Elasticsearch ingest pipeline to apply pre-process transformations to before indexing. For example my_pipeline_id. Learn more about creating a pipeline in the Elasticsearch docs (https://www.elastic.co/guide/en/elasticsearch/reference/current/ingest.html)").Action(c.Password.Set).StringVar(&c.Pipeline.Value) - c.CmdClause.Flag("tls-ca-cert", "A secure certificate to authenticate the server with. Must be in PEM format").Action(c.TLSCACert.Set).StringVar(&c.TLSCACert.Value) - c.CmdClause.Flag("tls-client-cert", "The client certificate used to make authenticated requests. Must be in PEM format").Action(c.TLSClientCert.Set).StringVar(&c.TLSClientCert.Value) - c.CmdClause.Flag("tls-client-key", "The client private key used to make authenticated requests. Must be in PEM format").Action(c.TLSClientKey.Set).StringVar(&c.TLSClientKey.Value) - c.CmdClause.Flag("tls-hostname", "The hostname used to verify the server's certificate. It can either be the Common Name or a Subject Alternative Name (SAN)").Action(c.TLSHostname.Set).StringVar(&c.TLSHostname.Value) - c.CmdClause.Flag("format", "Apache style log formatting. Your log must produce valid JSON that Elasticsearch can ingest").Action(c.Format.Set).StringVar(&c.Format.Value) - c.CmdClause.Flag("format-version", "The version of the custom logging format used for the configured endpoint. Can be either 2 (default) or 1").Action(c.FormatVersion.Set).UintVar(&c.FormatVersion.Value) - c.CmdClause.Flag("placement", "Where in the generated VCL the logging call should be placed, overriding any format_version default. Can be none or waf_debug").Action(c.Placement.Set).StringVar(&c.Placement.Value) - c.CmdClause.Flag("response-condition", "The name of an existing condition in the configured endpoint, or leave blank to always execute").Action(c.ResponseCondition.Set).StringVar(&c.ResponseCondition.Value) - c.CmdClause.Flag("request-max-entries", "Maximum number of logs to append to a batch, if non-zero. Defaults to 0 for unbounded").Action(c.RequestMaxEntries.Set).UintVar(&c.RequestMaxEntries.Value) - c.CmdClause.Flag("request-max-bytes", "Maximum size of log batch, if non-zero. Defaults to 0 for unbounded").Action(c.RequestMaxBytes.Set).UintVar(&c.RequestMaxBytes.Value) - - return &c -} - -// createInput transforms values parsed from CLI flags into an object to be used by the API client library. -func (c *UpdateCommand) createInput() (*fastly.UpdateElasticsearchInput, error) { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return nil, errors.ErrNoServiceID - } - - input := fastly.UpdateElasticsearchInput{ - ServiceID: serviceID, - ServiceVersion: c.Version, - Name: c.EndpointName, - } - - if c.NewName.WasSet { - input.NewName = fastly.String(c.NewName.Value) - } - - if c.Index.WasSet { - input.Index = fastly.String(c.Index.Value) - } - - if c.URL.WasSet { - input.URL = fastly.String(c.URL.Value) - } - - if c.Pipeline.WasSet { - input.Pipeline = fastly.String(c.Pipeline.Value) - } - - if c.RequestMaxEntries.WasSet { - input.RequestMaxEntries = fastly.Uint(c.RequestMaxEntries.Value) - } - - if c.RequestMaxBytes.WasSet { - input.RequestMaxBytes = fastly.Uint(c.RequestMaxBytes.Value) - } - - if c.User.WasSet { - input.User = fastly.String(c.User.Value) - } - - if c.Password.WasSet { - input.Password = fastly.String(c.Password.Value) - } - - if c.TLSCACert.WasSet { - input.TLSCACert = fastly.String(c.TLSCACert.Value) - } - - if c.TLSClientCert.WasSet { - input.TLSClientCert = fastly.String(c.TLSClientCert.Value) - } - - if c.TLSClientKey.WasSet { - input.TLSClientKey = fastly.String(c.TLSClientKey.Value) - } - - if c.TLSHostname.WasSet { - input.TLSHostname = fastly.String(c.TLSHostname.Value) - } - - if c.Format.WasSet { - input.Format = fastly.String(c.Format.Value) - } - - if c.FormatVersion.WasSet { - input.FormatVersion = fastly.Uint(c.FormatVersion.Value) - } - - if c.ResponseCondition.WasSet { - input.ResponseCondition = fastly.String(c.ResponseCondition.Value) - } - - if c.Placement.WasSet { - input.Placement = fastly.String(c.Placement.Value) - } - - return &input, nil -} - -// Exec invokes the application logic for the command. -func (c *UpdateCommand) Exec(in io.Reader, out io.Writer) error { - input, err := c.createInput() - if err != nil { - return err - } - - elasticsearch, err := c.Globals.Client.UpdateElasticsearch(input) - if err != nil { - return err - } - - text.Success(out, "Updated Elasticsearch logging endpoint %s (service %s version %d)", elasticsearch.Name, elasticsearch.ServiceID, elasticsearch.ServiceVersion) - return nil -} diff --git a/pkg/logging/ftp/create.go b/pkg/logging/ftp/create.go deleted file mode 100644 index 4535348e6..000000000 --- a/pkg/logging/ftp/create.go +++ /dev/null @@ -1,148 +0,0 @@ -package ftp - -import ( - "fmt" - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// CreateCommand calls the Fastly API to create an FTP logging endpoint. -type CreateCommand struct { - common.Base - manifest manifest.Data - - // required - EndpointName string // Can't shadow common.Base method Name(). - Version int - Address string - Username string - Password string - - // optional - Port common.OptionalUint - Path common.OptionalString - Period common.OptionalUint - GzipLevel common.OptionalUint8 - Format common.OptionalString - FormatVersion common.OptionalUint - ResponseCondition common.OptionalString - TimestampFormat common.OptionalString - Placement common.OptionalString - CompressionCodec common.OptionalString -} - -// NewCreateCommand returns a usable command registered under the parent. -func NewCreateCommand(parent common.Registerer, globals *config.Data) *CreateCommand { - var c CreateCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("create", "Create an FTP logging endpoint on a Fastly service version").Alias("add") - - c.CmdClause.Flag("name", "The name of the FTP logging object. Used as a primary key for API access").Short('n').Required().StringVar(&c.EndpointName) - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Version) - c.CmdClause.Flag("address", "An hostname or IPv4 address").Required().StringVar(&c.Address) - c.CmdClause.Flag("user", "The username for the server (can be anonymous)").Required().StringVar(&c.Username) - c.CmdClause.Flag("password", "The password for the server (for anonymous use an email address)").Required().StringVar(&c.Password) - - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("port", "The port number").Action(c.Port.Set).UintVar(&c.Port.Value) - c.CmdClause.Flag("path", "The path to upload log files to. If the path ends in / then it is treated as a directory").Action(c.Path.Set).StringVar(&c.Path.Value) - c.CmdClause.Flag("period", "How frequently log files are finalized so they can be available for reading (in seconds, default 3600)").Action(c.Period.Set).UintVar(&c.Period.Value) - c.CmdClause.Flag("gzip-level", "What level of GZIP encoding to have when dumping logs (default 0, no compression)").Action(c.GzipLevel.Set).Uint8Var(&c.GzipLevel.Value) - c.CmdClause.Flag("format", "Apache style log formatting").Action(c.Format.Set).StringVar(&c.Format.Value) - c.CmdClause.Flag("format-version", "The version of the custom logging format used for the configured endpoint. Can be either 2 (default) or 1").Action(c.FormatVersion.Set).UintVar(&c.FormatVersion.Value) - c.CmdClause.Flag("response-condition", "The name of an existing condition in the configured endpoint, or leave blank to always execute").Action(c.ResponseCondition.Set).StringVar(&c.ResponseCondition.Value) - c.CmdClause.Flag("timestamp-format", `strftime specified timestamp formatting (default "%Y-%m-%dT%H:%M:%S.000")`).Action(c.TimestampFormat.Set).StringVar(&c.TimestampFormat.Value) - c.CmdClause.Flag("placement", "Where in the generated VCL the logging call should be placed, overriding any format_version default. Can be none or waf_debug").Action(c.Placement.Set).StringVar(&c.Placement.Value) - c.CmdClause.Flag("compression-codec", `The codec used for compression of your logs. Valid values are zstd, snappy, and gzip. If the specified codec is "gzip", gzip_level will default to 3. To specify a different level, leave compression_codec blank and explicitly set the level using gzip_level. Specifying both compression_codec and gzip_level in the same API request will result in an error.`).Action(c.CompressionCodec.Set).StringVar(&c.CompressionCodec.Value) - - return &c -} - -// createInput transforms values parsed from CLI flags into an object to be used by the API client library. -func (c *CreateCommand) createInput() (*fastly.CreateFTPInput, error) { - var input fastly.CreateFTPInput - - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return nil, errors.ErrNoServiceID - } - - input.ServiceID = serviceID - input.ServiceVersion = c.Version - input.Name = c.EndpointName - input.Address = c.Address - input.Username = c.Username - input.Password = c.Password - - // The following blocks enforces the mutual exclusivity of the - // CompressionCodec and GzipLevel flags. - if c.CompressionCodec.WasSet && c.GzipLevel.WasSet { - return nil, fmt.Errorf("error parsing arguments: the --compression-codec flag is mutually exclusive with the --gzip-level flag") - } - - if c.Port.WasSet { - input.Port = c.Port.Value - } - - if c.Path.WasSet { - input.Path = c.Path.Value - } - - if c.Period.WasSet { - input.Period = c.Period.Value - } - - if c.Format.WasSet { - input.Format = c.Format.Value - } - - if c.FormatVersion.WasSet { - input.FormatVersion = c.FormatVersion.Value - } - - if c.GzipLevel.WasSet { - input.GzipLevel = c.GzipLevel.Value - } - - if c.ResponseCondition.WasSet { - input.ResponseCondition = c.ResponseCondition.Value - } - - if c.TimestampFormat.WasSet { - input.TimestampFormat = c.TimestampFormat.Value - } - - if c.Placement.WasSet { - input.Placement = c.Placement.Value - } - - if c.CompressionCodec.WasSet { - input.CompressionCodec = c.CompressionCodec.Value - } - - return &input, nil -} - -// Exec invokes the application logic for the command. -func (c *CreateCommand) Exec(in io.Reader, out io.Writer) error { - input, err := c.createInput() - if err != nil { - return err - } - - d, err := c.Globals.Client.CreateFTP(input) - if err != nil { - return err - } - - text.Success(out, "Created FTP logging endpoint %s (service %s version %d)", d.Name, d.ServiceID, d.ServiceVersion) - return nil -} diff --git a/pkg/logging/ftp/delete.go b/pkg/logging/ftp/delete.go deleted file mode 100644 index 7928e047d..000000000 --- a/pkg/logging/ftp/delete.go +++ /dev/null @@ -1,51 +0,0 @@ -package ftp - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// DeleteCommand calls the Fastly API to delete an FTP logging endpoint. -type DeleteCommand struct { - common.Base - manifest manifest.Data - Input fastly.DeleteFTPInput -} - -// NewDeleteCommand returns a usable command registered under the parent. -func NewDeleteCommand(parent common.Registerer, globals *config.Data) *DeleteCommand { - var c DeleteCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("delete", "Delete an FTP logging endpoint on a Fastly service version").Alias("remove") - - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Input.ServiceVersion) - c.CmdClause.Flag("name", "The name of the FTP logging object").Short('n').Required().StringVar(&c.Input.Name) - - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - - return &c -} - -// Exec invokes the application logic for the command. -func (c *DeleteCommand) Exec(in io.Reader, out io.Writer) error { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - c.Input.ServiceID = serviceID - - if err := c.Globals.Client.DeleteFTP(&c.Input); err != nil { - return err - } - - text.Success(out, "Deleted FTP logging endpoint %s (service %s version %d)", c.Input.Name, c.Input.ServiceID, c.Input.ServiceVersion) - return nil -} diff --git a/pkg/logging/ftp/describe.go b/pkg/logging/ftp/describe.go deleted file mode 100644 index bdf174e93..000000000 --- a/pkg/logging/ftp/describe.go +++ /dev/null @@ -1,66 +0,0 @@ -package ftp - -import ( - "fmt" - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/go-fastly/v3/fastly" -) - -// DescribeCommand calls the Fastly API to describe an FTP logging endpoint. -type DescribeCommand struct { - common.Base - manifest manifest.Data - Input fastly.GetFTPInput -} - -// NewDescribeCommand returns a usable command registered under the parent. -func NewDescribeCommand(parent common.Registerer, globals *config.Data) *DescribeCommand { - var c DescribeCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("describe", "Show detailed information about an FTP logging endpoint on a Fastly service version").Alias("get") - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Input.ServiceVersion) - c.CmdClause.Flag("name", "The name of the FTP logging object").Short('n').Required().StringVar(&c.Input.Name) - return &c -} - -// Exec invokes the application logic for the command. -func (c *DescribeCommand) Exec(in io.Reader, out io.Writer) error { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - c.Input.ServiceID = serviceID - - ftp, err := c.Globals.Client.GetFTP(&c.Input) - if err != nil { - return err - } - - fmt.Fprintf(out, "Service ID: %s\n", ftp.ServiceID) - fmt.Fprintf(out, "Version: %d\n", ftp.ServiceVersion) - fmt.Fprintf(out, "Name: %s\n", ftp.Name) - fmt.Fprintf(out, "Address: %s\n", ftp.Address) - fmt.Fprintf(out, "Port: %d\n", ftp.Port) - fmt.Fprintf(out, "Username: %s\n", ftp.Username) - fmt.Fprintf(out, "Password: %s\n", ftp.Password) - fmt.Fprintf(out, "Public key: %s\n", ftp.PublicKey) - fmt.Fprintf(out, "Path: %s\n", ftp.Path) - fmt.Fprintf(out, "Period: %d\n", ftp.Period) - fmt.Fprintf(out, "GZip level: %d\n", ftp.GzipLevel) - fmt.Fprintf(out, "Format: %s\n", ftp.Format) - fmt.Fprintf(out, "Format version: %d\n", ftp.FormatVersion) - fmt.Fprintf(out, "Response condition: %s\n", ftp.ResponseCondition) - fmt.Fprintf(out, "Timestamp format: %s\n", ftp.TimestampFormat) - fmt.Fprintf(out, "Placement: %s\n", ftp.Placement) - fmt.Fprintf(out, "Compression codec: %s\n", ftp.CompressionCodec) - - return nil -} diff --git a/pkg/logging/ftp/ftp_integration_test.go b/pkg/logging/ftp/ftp_integration_test.go deleted file mode 100644 index e50cba1b1..000000000 --- a/pkg/logging/ftp/ftp_integration_test.go +++ /dev/null @@ -1,475 +0,0 @@ -package ftp_test - -import ( - "bytes" - "errors" - "io" - "net/http" - "strings" - "testing" - - "github.com/fastly/cli/pkg/app" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/mock" - "github.com/fastly/cli/pkg/testutil" - "github.com/fastly/cli/pkg/update" - "github.com/fastly/go-fastly/v3/fastly" -) - -func TestFTPCreate(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "ftp", "create", "--service-id", "123", "--version", "1", "--name", "log", "--user", "anonymous", "--password", "foo@example.com"}, - wantError: "error parsing arguments: required flag --address not provided", - }, - { - args: []string{"logging", "ftp", "create", "--service-id", "123", "--version", "1", "--name", "log", "--address", "example.com", "--password", "foo@example.com"}, - wantError: "error parsing arguments: required flag --user not provided", - }, - { - args: []string{"logging", "ftp", "create", "--service-id", "123", "--version", "1", "--name", "log", "--address", "example.com", "--user", "anonymous"}, - wantError: "error parsing arguments: required flag --password not provided", - }, - { - args: []string{"logging", "ftp", "create", "--service-id", "123", "--version", "1", "--name", "log", "--address", "example.com", "--user", "anonymous", "--password", "foo@example.com", "--compression-codec", "zstd"}, - api: mock.API{CreateFTPFn: createFTPOK}, - wantOutput: "Created FTP logging endpoint log (service 123 version 1)", - }, - { - args: []string{"logging", "ftp", "create", "--service-id", "123", "--version", "1", "--name", "log", "--address", "example.com", "--user", "anonymous", "--password", "foo@example.com"}, - api: mock.API{CreateFTPFn: createFTPError}, - wantError: errTest.Error(), - }, - { - args: []string{"logging", "ftp", "create", "--service-id", "123", "--version", "1", "--name", "log", "--address", "example.com", "--user", "anonymous", "--password", "foo@example.com", "--compression-codec", "zstd", "--gzip-level", "9"}, - wantError: "error parsing arguments: the --compression-codec flag is mutually exclusive with the --gzip-level flag", - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertStringContains(t, out.String(), testcase.wantOutput) - }) - } -} - -func TestFTPList(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "ftp", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListFTPsFn: listFTPsOK}, - wantOutput: listFTPsShortOutput, - }, - { - args: []string{"logging", "ftp", "list", "--service-id", "123", "--version", "1", "--verbose"}, - api: mock.API{ListFTPsFn: listFTPsOK}, - wantOutput: listFTPsVerboseOutput, - }, - { - args: []string{"logging", "ftp", "list", "--service-id", "123", "--version", "1", "-v"}, - api: mock.API{ListFTPsFn: listFTPsOK}, - wantOutput: listFTPsVerboseOutput, - }, - { - args: []string{"logging", "ftp", "--verbose", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListFTPsFn: listFTPsOK}, - wantOutput: listFTPsVerboseOutput, - }, - { - args: []string{"logging", "-v", "ftp", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListFTPsFn: listFTPsOK}, - wantOutput: listFTPsVerboseOutput, - }, - { - args: []string{"logging", "ftp", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListFTPsFn: listFTPsError}, - wantError: errTest.Error(), - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertString(t, testcase.wantOutput, out.String()) - }) - } -} - -func TestFTPDescribe(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "ftp", "describe", "--service-id", "123", "--version", "1"}, - wantError: "error parsing arguments: required flag --name not provided", - }, - { - args: []string{"logging", "ftp", "describe", "--service-id", "123", "--version", "1", "--name", "logs"}, - api: mock.API{GetFTPFn: getFTPError}, - wantError: errTest.Error(), - }, - { - args: []string{"logging", "ftp", "describe", "--service-id", "123", "--version", "1", "--name", "logs"}, - api: mock.API{GetFTPFn: getFTPOK}, - wantOutput: describeFTPOutput, - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertString(t, testcase.wantOutput, out.String()) - }) - } -} - -func TestFTPUpdate(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "ftp", "update", "--service-id", "123", "--version", "1", "--new-name", "log"}, - wantError: "error parsing arguments: required flag --name not provided", - }, - { - args: []string{"logging", "ftp", "update", "--service-id", "123", "--version", "1", "--name", "logs", "--new-name", "log"}, - api: mock.API{UpdateFTPFn: updateFTPError}, - wantError: errTest.Error(), - }, - { - args: []string{"logging", "ftp", "update", "--service-id", "123", "--version", "1", "--name", "logs", "--new-name", "log"}, - api: mock.API{UpdateFTPFn: updateFTPOK}, - wantOutput: "Updated FTP logging endpoint log (service 123 version 1)", - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertStringContains(t, out.String(), testcase.wantOutput) - }) - } -} - -func TestFTPDelete(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "ftp", "delete", "--service-id", "123", "--version", "1"}, - wantError: "error parsing arguments: required flag --name not provided", - }, - { - args: []string{"logging", "ftp", "delete", "--service-id", "123", "--version", "1", "--name", "logs"}, - api: mock.API{DeleteFTPFn: deleteFTPError}, - wantError: errTest.Error(), - }, - { - args: []string{"logging", "ftp", "delete", "--service-id", "123", "--version", "1", "--name", "logs"}, - api: mock.API{DeleteFTPFn: deleteFTPOK}, - wantOutput: "Deleted FTP logging endpoint logs (service 123 version 1)", - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertStringContains(t, out.String(), testcase.wantOutput) - }) - } -} - -var errTest = errors.New("fixture error") - -func createFTPOK(i *fastly.CreateFTPInput) (*fastly.FTP, error) { - return &fastly.FTP{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: i.Name, - CompressionCodec: i.CompressionCodec, - }, nil -} - -func createFTPError(i *fastly.CreateFTPInput) (*fastly.FTP, error) { - return nil, errTest -} - -func listFTPsOK(i *fastly.ListFTPsInput) ([]*fastly.FTP, error) { - return []*fastly.FTP{ - { - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "logs", - Address: "example.com", - Port: 123, - Username: "anonymous", - Password: "foo@example.com", - PublicKey: pgpPublicKey(), - Path: "logs/", - Period: 3600, - GzipLevel: 9, - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - ResponseCondition: "Prevent default logging", - TimestampFormat: "%Y-%m-%dT%H:%M:%S.000", - Placement: "none", - CompressionCodec: "zstd", - }, - { - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "analytics", - Address: "127.0.0.1", - Port: 456, - Username: "foo", - Password: "password", - PublicKey: pgpPublicKey(), - Path: "logs/", - Period: 86400, - GzipLevel: 9, - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - ResponseCondition: "Prevent default logging", - TimestampFormat: "%Y-%m-%dT%H:%M:%S.000", - Placement: "none", - CompressionCodec: "zstd", - }, - }, nil -} - -func listFTPsError(i *fastly.ListFTPsInput) ([]*fastly.FTP, error) { - return nil, errTest -} - -var listFTPsShortOutput = strings.TrimSpace(` -SERVICE VERSION NAME -123 1 logs -123 1 analytics -`) + "\n" - -var listFTPsVerboseOutput = strings.TrimSpace(` -Fastly API token not provided -Fastly API endpoint: https://api.fastly.com -Service ID: 123 -Version: 1 - FTP 1/2 - Service ID: 123 - Version: 1 - Name: logs - Address: example.com - Port: 123 - Username: anonymous - Password: foo@example.com - Public key: `+pgpPublicKey()+` - Path: logs/ - Period: 3600 - GZip level: 9 - Format: %h %l %u %t "%r" %>s %b - Format version: 2 - Response condition: Prevent default logging - Timestamp format: %Y-%m-%dT%H:%M:%S.000 - Placement: none - Compression codec: zstd - FTP 2/2 - Service ID: 123 - Version: 1 - Name: analytics - Address: 127.0.0.1 - Port: 456 - Username: foo - Password: password - Public key: `+pgpPublicKey()+` - Path: logs/ - Period: 86400 - GZip level: 9 - Format: %h %l %u %t "%r" %>s %b - Format version: 2 - Response condition: Prevent default logging - Timestamp format: %Y-%m-%dT%H:%M:%S.000 - Placement: none - Compression codec: zstd -`) + "\n\n" - -func getFTPOK(i *fastly.GetFTPInput) (*fastly.FTP, error) { - return &fastly.FTP{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "logs", - Address: "example.com", - Port: 123, - Username: "anonymous", - Password: "foo@example.com", - PublicKey: pgpPublicKey(), - Path: "logs/", - Period: 3600, - GzipLevel: 9, - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - ResponseCondition: "Prevent default logging", - TimestampFormat: "%Y-%m-%dT%H:%M:%S.000", - Placement: "none", - CompressionCodec: "zstd", - }, nil -} - -func getFTPError(i *fastly.GetFTPInput) (*fastly.FTP, error) { - return nil, errTest -} - -var describeFTPOutput = strings.TrimSpace(` -Service ID: 123 -Version: 1 -Name: logs -Address: example.com -Port: 123 -Username: anonymous -Password: foo@example.com -Public key: `+pgpPublicKey()+` -Path: logs/ -Period: 3600 -GZip level: 9 -Format: %h %l %u %t "%r" %>s %b -Format version: 2 -Response condition: Prevent default logging -Timestamp format: %Y-%m-%dT%H:%M:%S.000 -Placement: none -Compression codec: zstd -`) + "\n" - -func updateFTPOK(i *fastly.UpdateFTPInput) (*fastly.FTP, error) { - return &fastly.FTP{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "log", - Address: "example.com", - Port: 123, - Username: "anonymous", - Password: "foo@example.com", - PublicKey: pgpPublicKey(), - Path: "logs/", - Period: 3600, - GzipLevel: 9, - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - ResponseCondition: "Prevent default logging", - TimestampFormat: "%Y-%m-%dT%H:%M:%S.000", - Placement: "none", - CompressionCodec: "zstd", - }, nil -} - -func updateFTPError(i *fastly.UpdateFTPInput) (*fastly.FTP, error) { - return nil, errTest -} - -func deleteFTPOK(i *fastly.DeleteFTPInput) error { - return nil -} - -func deleteFTPError(i *fastly.DeleteFTPInput) error { - return errTest -} - -// pgpPublicKey returns a PEM encoded PGP public key suitable for testing. -func pgpPublicKey() string { - return strings.TrimSpace(`-----BEGIN PGP PUBLIC KEY BLOCK----- -mQENBFyUD8sBCACyFnB39AuuTygseek+eA4fo0cgwva6/FSjnWq7riouQee8GgQ/ -ibXTRyv4iVlwI12GswvMTIy7zNvs1R54i0qvsLr+IZ4GVGJqs6ZJnvQcqe3xPoR4 -8AnBfw90o32r/LuHf6QCJXi+AEu35koNlNAvLJ2B+KACaNB7N0EeWmqpV/1V2k9p -lDYk+th7LcCuaFNGqKS/PrMnnMqR6VDLCjHhNx4KR79b0Twm/2qp6an3hyNRu8Gn -dwxpf1/BUu3JWf+LqkN4Y3mbOmSUL3MaJNvyQguUzTfS0P0uGuBDHrJCVkMZCzDB -89ag55jCPHyGeHBTd02gHMWzsg3WMBWvCsrzABEBAAG0JXRlcnJhZm9ybSAodGVz -dCkgPHRlc3RAdGVycmFmb3JtLmNvbT6JAU4EEwEIADgWIQSHYyc6Kj9l6HzQsau6 -vFFc9jxV/wUCXJQPywIbAwULCQgHAgYVCgkICwIEFgIDAQIeAQIXgAAKCRC6vFFc -9jxV/815CAClb32OxV7wG01yF97TzlyTl8TnvjMtoG29Mw4nSyg+mjM3b8N7iXm9 -OLX59fbDAWtBSldSZE22RXd3CvlFOG/EnKBXSjBtEqfyxYSnyOPkMPBYWGL/ApkX -SvPYJ4LKdvipYToKFh3y9kk2gk1DcDBDyaaHvR+3rv1u3aoy7/s2EltAfDS3ZQIq -7/cWTLJml/lleeB/Y6rPj8xqeCYhE5ahw9gsV/Mdqatl24V9Tks30iijx0Hhw+Gx -kATUikMGr2GDVqoIRga5kXI7CzYff4rkc0Twn47fMHHHe/KY9M2yVnMHUXmAZwbG -M1cMI/NH1DjevCKdGBLcRJlhuLPKF/anuQENBFyUD8sBCADIpd7r7GuPd6n/Ikxe -u6h7umV6IIPoAm88xCYpTbSZiaK30Svh6Ywra9jfE2KlU9o6Y/art8ip0VJ3m07L -4RSfSpnzqgSwdjSq5hNour2Fo/BzYhK7yaz2AzVSbe33R0+RYhb4b/6N+bKbjwGF -ftCsqVFMH+PyvYkLbvxyQrHlA9woAZaNThI1ztO5rGSnGUR8xt84eup28WIFKg0K -UEGUcTzz+8QGAwAra+0ewPXo/AkO+8BvZjDidP417u6gpBHOJ9qYIcO9FxHeqFyu -YrjlrxowEgXn5wO8xuNz6Vu1vhHGDHGDsRbZF8pv1d5O+0F1G7ttZ2GRRgVBZPwi -kiyRABEBAAGJATYEGAEIACAWIQSHYyc6Kj9l6HzQsau6vFFc9jxV/wUCXJQPywIb -DAAKCRC6vFFc9jxV/9YOCACe8qmOSnKQpQfW+PqYOqo3dt7JyweTs3FkD6NT8Zml -dYy/vkstbTjPpX6aTvUZjkb46BVi7AOneVHpD5GBqvRsZ9iVgDYHaehmLCdKiG5L -3Tp90NN+QY5WDbsGmsyk6+6ZMYejb4qYfweQeduOj27aavCJdLkCYMoRKfcFYI8c -FaNmEfKKy/r1PO20NXEG6t9t05K/frHy6ZG8bCNYdpagfFVot47r9JaQqWlTNtIR -5+zkkSq/eG9BEtRij3a6cTdQbktdBzx2KBeI0PYc1vlZR0LpuFKZqY9vlE6vTGLR -wMfrTEOvx0NxUM3rpaCgEmuWbB1G1Hu371oyr4srrr+N -=28dr ------END PGP PUBLIC KEY BLOCK----- -`) -} diff --git a/pkg/logging/ftp/ftp_test.go b/pkg/logging/ftp/ftp_test.go deleted file mode 100644 index ae7a4cd0a..000000000 --- a/pkg/logging/ftp/ftp_test.go +++ /dev/null @@ -1,225 +0,0 @@ -package ftp - -import ( - "testing" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/mock" - "github.com/fastly/cli/pkg/testutil" - "github.com/fastly/go-fastly/v3/fastly" -) - -func TestCreateFTPInput(t *testing.T) { - for _, testcase := range []struct { - name string - cmd *CreateCommand - want *fastly.CreateFTPInput - wantError string - }{ - { - name: "required values set flag serviceID", - cmd: createCommandRequired(), - want: &fastly.CreateFTPInput{ - ServiceID: "123", - ServiceVersion: 2, - Name: "log", - Address: "example.com", - Username: "user", - Password: "password", - }, - }, - { - name: "all values set flag serviceID", - cmd: createCommandAll(), - want: &fastly.CreateFTPInput{ - ServiceID: "123", - ServiceVersion: 2, - Name: "log", - Address: "example.com", - Port: 22, - Username: "user", - Password: "password", - Path: "/logs", - Period: 3600, - FormatVersion: 2, - Format: `%h %l %u %t "%r" %>s %b`, - ResponseCondition: "Prevent default logging", - TimestampFormat: "%Y-%m-%dT%H:%M:%S.000", - Placement: "none", - CompressionCodec: "zstd", - }, - }, - { - name: "error missing serviceID", - cmd: createCommandMissingServiceID(), - want: nil, - wantError: errors.ErrNoServiceID.Error(), - }, - } { - t.Run(testcase.name, func(t *testing.T) { - have, err := testcase.cmd.createInput() - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertEqual(t, testcase.want, have) - }) - } -} - -func TestUpdateFTPInput(t *testing.T) { - for _, testcase := range []struct { - name string - cmd *UpdateCommand - api mock.API - want *fastly.UpdateFTPInput - wantError string - }{ - { - name: "no updates", - cmd: updateCommandNoUpdates(), - api: mock.API{GetFTPFn: getFTPOK}, - want: &fastly.UpdateFTPInput{ - ServiceID: "123", - ServiceVersion: 2, - Name: "log", - }, - }, - { - name: "all values set flag serviceID", - cmd: updateCommandAll(), - api: mock.API{GetFTPFn: getFTPOK}, - want: &fastly.UpdateFTPInput{ - ServiceID: "123", - ServiceVersion: 2, - Name: "log", - NewName: fastly.String("new1"), - Address: fastly.String("new2"), - Port: fastly.Uint(23), - PublicKey: fastly.String("new10"), - Username: fastly.String("new3"), - Password: fastly.String("new4"), - Path: fastly.String("new5"), - Period: fastly.Uint(3601), - FormatVersion: fastly.Uint(3), - GzipLevel: fastly.Uint8(0), - Format: fastly.String("new6"), - ResponseCondition: fastly.String("new7"), - TimestampFormat: fastly.String("new8"), - Placement: fastly.String("new9"), - CompressionCodec: fastly.String("new11"), - }, - }, - { - name: "error missing serviceID", - cmd: updateCommandMissingServiceID(), - want: nil, - wantError: errors.ErrNoServiceID.Error(), - }, - } { - t.Run(testcase.name, func(t *testing.T) { - testcase.cmd.Base.Globals.Client = testcase.api - - have, err := testcase.cmd.createInput() - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertEqual(t, testcase.want, have) - }) - } -} - -func createCommandRequired() *CreateCommand { - return &CreateCommand{ - manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, - EndpointName: "log", - Address: "example.com", - Username: "user", - Password: "password", - Version: 2, - } -} - -func createCommandAll() *CreateCommand { - return &CreateCommand{ - manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, - EndpointName: "log", - Version: 2, - Address: "example.com", - Username: "user", - Password: "password", - Port: common.OptionalUint{Optional: common.Optional{WasSet: true}, Value: 22}, - Path: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "/logs"}, - Period: common.OptionalUint{Optional: common.Optional{WasSet: true}, Value: 3600}, - Format: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: `%h %l %u %t "%r" %>s %b`}, - FormatVersion: common.OptionalUint{Optional: common.Optional{WasSet: true}, Value: 2}, - TimestampFormat: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "%Y-%m-%dT%H:%M:%S.000"}, - ResponseCondition: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "Prevent default logging"}, - Placement: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "none"}, - CompressionCodec: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "zstd"}, - } -} - -func createCommandMissingServiceID() *CreateCommand { - res := createCommandAll() - res.manifest = manifest.Data{} - return res -} - -func updateCommandNoUpdates() *UpdateCommand { - return &UpdateCommand{ - Base: common.Base{Globals: &config.Data{Client: nil}}, - manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, - EndpointName: "log", - Version: 2, - } -} - -func updateCommandAll() *UpdateCommand { - return &UpdateCommand{ - Base: common.Base{Globals: &config.Data{Client: nil}}, - manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, - EndpointName: "log", - Version: 2, - NewName: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new1"}, - Address: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new2"}, - Port: common.OptionalUint{Optional: common.Optional{WasSet: true}, Value: 23}, - Username: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new3"}, - Password: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new4"}, - PublicKey: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new10"}, - Path: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new5"}, - Period: common.OptionalUint{Optional: common.Optional{WasSet: true}, Value: 3601}, - GzipLevel: common.OptionalUint8{Optional: common.Optional{WasSet: true}, Value: 0}, - Format: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new6"}, - FormatVersion: common.OptionalUint{Optional: common.Optional{WasSet: true}, Value: 3}, - ResponseCondition: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new7"}, - TimestampFormat: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new8"}, - Placement: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new9"}, - CompressionCodec: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new11"}, - } -} - -func updateCommandMissingServiceID() *UpdateCommand { - res := updateCommandAll() - res.manifest = manifest.Data{} - return res -} - -func getFTPOK(i *fastly.GetFTPInput) (*fastly.FTP, error) { - return &fastly.FTP{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "logs", - Address: "example.com", - Port: 22, - Username: "user", - Password: "password", - Path: "/logs", - Period: 3600, - GzipLevel: 0, - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - ResponseCondition: "Prevent default logging", - TimestampFormat: "%Y-%m-%dT%H:%M:%S.000", - Placement: "none", - CompressionCodec: "zstd", - }, nil -} diff --git a/pkg/logging/ftp/list.go b/pkg/logging/ftp/list.go deleted file mode 100644 index 592bd7c52..000000000 --- a/pkg/logging/ftp/list.go +++ /dev/null @@ -1,82 +0,0 @@ -package ftp - -import ( - "fmt" - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// ListCommand calls the Fastly API to list FTP logging endpoints. -type ListCommand struct { - common.Base - manifest manifest.Data - Input fastly.ListFTPsInput -} - -// NewListCommand returns a usable command registered under the parent. -func NewListCommand(parent common.Registerer, globals *config.Data) *ListCommand { - var c ListCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("list", "List FTP endpoints on a Fastly service version") - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Input.ServiceVersion) - return &c -} - -// Exec invokes the application logic for the command. -func (c *ListCommand) Exec(in io.Reader, out io.Writer) error { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - c.Input.ServiceID = serviceID - - ftps, err := c.Globals.Client.ListFTPs(&c.Input) - if err != nil { - return err - } - - if !c.Globals.Verbose() { - tw := text.NewTable(out) - tw.AddHeader("SERVICE", "VERSION", "NAME") - for _, ftp := range ftps { - tw.AddLine(ftp.ServiceID, ftp.ServiceVersion, ftp.Name) - } - tw.Print() - return nil - } - - fmt.Fprintf(out, "Service ID: %s\n", c.Input.ServiceID) - fmt.Fprintf(out, "Version: %d\n", c.Input.ServiceVersion) - for i, ftp := range ftps { - fmt.Fprintf(out, "\tFTP %d/%d\n", i+1, len(ftps)) - fmt.Fprintf(out, "\t\tService ID: %s\n", ftp.ServiceID) - fmt.Fprintf(out, "\t\tVersion: %d\n", ftp.ServiceVersion) - fmt.Fprintf(out, "\t\tName: %s\n", ftp.Name) - fmt.Fprintf(out, "\t\tAddress: %s\n", ftp.Address) - fmt.Fprintf(out, "\t\tPort: %d\n", ftp.Port) - fmt.Fprintf(out, "\t\tUsername: %s\n", ftp.Username) - fmt.Fprintf(out, "\t\tPassword: %s\n", ftp.Password) - fmt.Fprintf(out, "\t\tPublic key: %s\n", ftp.PublicKey) - fmt.Fprintf(out, "\t\tPath: %s\n", ftp.Path) - fmt.Fprintf(out, "\t\tPeriod: %d\n", ftp.Period) - fmt.Fprintf(out, "\t\tGZip level: %d\n", ftp.GzipLevel) - fmt.Fprintf(out, "\t\tFormat: %s\n", ftp.Format) - fmt.Fprintf(out, "\t\tFormat version: %d\n", ftp.FormatVersion) - fmt.Fprintf(out, "\t\tResponse condition: %s\n", ftp.ResponseCondition) - fmt.Fprintf(out, "\t\tTimestamp format: %s\n", ftp.TimestampFormat) - fmt.Fprintf(out, "\t\tPlacement: %s\n", ftp.Placement) - fmt.Fprintf(out, "\t\tCompression codec: %s\n", ftp.CompressionCodec) - } - fmt.Fprintln(out) - - return nil -} diff --git a/pkg/logging/ftp/root.go b/pkg/logging/ftp/root.go deleted file mode 100644 index 5030ec3bd..000000000 --- a/pkg/logging/ftp/root.go +++ /dev/null @@ -1,28 +0,0 @@ -package ftp - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/config" -) - -// RootCommand is the parent command for all subcommands in this package. -// It should be installed under the primary root command. -type RootCommand struct { - common.Base - // no flags -} - -// NewRootCommand returns a new command registered in the parent. -func NewRootCommand(parent common.Registerer, globals *config.Data) *RootCommand { - var c RootCommand - c.Globals = globals - c.CmdClause = parent.Command("ftp", "Manipulate Fastly service version FTP logging endpoints") - return &c -} - -// Exec implements the command interface. -func (c *RootCommand) Exec(in io.Reader, out io.Writer) error { - panic("unreachable") -} diff --git a/pkg/logging/ftp/update.go b/pkg/logging/ftp/update.go deleted file mode 100644 index c238e3209..000000000 --- a/pkg/logging/ftp/update.go +++ /dev/null @@ -1,164 +0,0 @@ -package ftp - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// UpdateCommand calls the Fastly API to update an FTP logging endpoint. -type UpdateCommand struct { - common.Base - manifest manifest.Data - - // required - EndpointName string // Can't shadow common.Base method Name(). - Version int - - // optional - NewName common.OptionalString - Address common.OptionalString - Port common.OptionalUint - Username common.OptionalString - Password common.OptionalString - PublicKey common.OptionalString - Path common.OptionalString - Period common.OptionalUint - GzipLevel common.OptionalUint8 - Format common.OptionalString - FormatVersion common.OptionalUint - ResponseCondition common.OptionalString - TimestampFormat common.OptionalString - Placement common.OptionalString - CompressionCodec common.OptionalString -} - -// NewUpdateCommand returns a usable command registered under the parent. -func NewUpdateCommand(parent common.Registerer, globals *config.Data) *UpdateCommand { - var c UpdateCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - - c.CmdClause = parent.Command("update", "Update an FTP logging endpoint on a Fastly service version") - - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Version) - c.CmdClause.Flag("name", "The name of the FTP logging object").Short('n').Required().StringVar(&c.EndpointName) - - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("new-name", "New name of the FTP logging object").Action(c.NewName.Set).StringVar(&c.NewName.Value) - c.CmdClause.Flag("address", "An hostname or IPv4 address").Action(c.Address.Set).StringVar(&c.Address.Value) - c.CmdClause.Flag("port", "The port number").Action(c.Port.Set).UintVar(&c.Port.Value) - c.CmdClause.Flag("username", "The username for the server (can be anonymous)").Action(c.Username.Set).StringVar(&c.Username.Value) - c.CmdClause.Flag("password", "The password for the server (for anonymous use an email address)").Action(c.Password.Set).StringVar(&c.Password.Value) - c.CmdClause.Flag("public-key", "A PGP public key that Fastly will use to encrypt your log files before writing them to disk").Action(c.PublicKey.Set).StringVar(&c.PublicKey.Value) - c.CmdClause.Flag("path", "The path to upload log files to. If the path ends in / then it is treated as a directory").Action(c.Path.Set).StringVar(&c.Path.Value) - c.CmdClause.Flag("period", "How frequently log files are finalized so they can be available for reading (in seconds, default 3600)").Action(c.Period.Set).UintVar(&c.Period.Value) - c.CmdClause.Flag("gzip-level", "What level of GZIP encoding to have when dumping logs (default 0, no compression)").Action(c.GzipLevel.Set).Uint8Var(&c.GzipLevel.Value) - c.CmdClause.Flag("format", "Apache style log formatting").Action(c.Format.Set).StringVar(&c.Format.Value) - c.CmdClause.Flag("format-version", "The version of the custom logging format used for the configured endpoint. Can be either 2 (the default, version 2 log format) or 1 (the version 1 log format). The logging call gets placed by default in vcl_log if format_version is set to 2 and in vcl_deliver if format_version is set to 1").Action(c.FormatVersion.Set).UintVar(&c.FormatVersion.Value) - c.CmdClause.Flag("response-condition", "The name of an existing condition in the configured endpoint, or leave blank to always execute").Action(c.ResponseCondition.Set).StringVar(&c.ResponseCondition.Value) - c.CmdClause.Flag("timestamp-format", `strftime specified timestamp formatting (default "%Y-%m-%dT%H:%M:%S.000")`).Action(c.TimestampFormat.Set).StringVar(&c.TimestampFormat.Value) - c.CmdClause.Flag("placement", "Where in the generated VCL the logging call should be placed, overriding any format_version default. Can be none or waf_debug").Action(c.Placement.Set).StringVar(&c.Placement.Value) - c.CmdClause.Flag("compression-codec", `The codec used for compression of your logs. Valid values are zstd, snappy, and gzip. If the specified codec is "gzip", gzip_level will default to 3. To specify a different level, leave compression_codec blank and explicitly set the level using gzip_level. Specifying both compression_codec and gzip_level in the same API request will result in an error.`).Action(c.CompressionCodec.Set).StringVar(&c.CompressionCodec.Value) - - return &c -} - -// createInput transforms values parsed from CLI flags into an object to be used by the API client library. -func (c *UpdateCommand) createInput() (*fastly.UpdateFTPInput, error) { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return nil, errors.ErrNoServiceID - } - - input := fastly.UpdateFTPInput{ - ServiceID: serviceID, - ServiceVersion: c.Version, - Name: c.EndpointName, - } - - // Set new values if set by user. - if c.NewName.WasSet { - input.NewName = fastly.String(c.NewName.Value) - } - - if c.Address.WasSet { - input.Address = fastly.String(c.Address.Value) - } - - if c.Port.WasSet { - input.Port = fastly.Uint(c.Port.Value) - } - - if c.Username.WasSet { - input.Username = fastly.String(c.Username.Value) - } - - if c.Password.WasSet { - input.Password = fastly.String(c.Password.Value) - } - - if c.PublicKey.WasSet { - input.PublicKey = fastly.String(c.PublicKey.Value) - } - - if c.Path.WasSet { - input.Path = fastly.String(c.Path.Value) - } - - if c.Period.WasSet { - input.Period = fastly.Uint(c.Period.Value) - } - - if c.FormatVersion.WasSet { - input.FormatVersion = fastly.Uint(c.FormatVersion.Value) - } - - if c.GzipLevel.WasSet { - input.GzipLevel = fastly.Uint8(c.GzipLevel.Value) - } - - if c.Format.WasSet { - input.Format = fastly.String(c.Format.Value) - } - - if c.ResponseCondition.WasSet { - input.ResponseCondition = fastly.String(c.ResponseCondition.Value) - } - - if c.TimestampFormat.WasSet { - input.TimestampFormat = fastly.String(c.TimestampFormat.Value) - } - - if c.Placement.WasSet { - input.Placement = fastly.String(c.Placement.Value) - } - - if c.CompressionCodec.WasSet { - input.CompressionCodec = fastly.String(c.CompressionCodec.Value) - } - - return &input, nil -} - -// Exec invokes the application logic for the command. -func (c *UpdateCommand) Exec(in io.Reader, out io.Writer) error { - input, err := c.createInput() - if err != nil { - return err - } - - ftp, err := c.Globals.Client.UpdateFTP(input) - if err != nil { - return err - } - - text.Success(out, "Updated FTP logging endpoint %s (service %s version %d)", ftp.Name, ftp.ServiceID, ftp.ServiceVersion) - return nil -} diff --git a/pkg/logging/gcs/create.go b/pkg/logging/gcs/create.go deleted file mode 100644 index 03e80ce5e..000000000 --- a/pkg/logging/gcs/create.go +++ /dev/null @@ -1,148 +0,0 @@ -package gcs - -import ( - "fmt" - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// CreateCommand calls the Fastly API to create a GCS logging endpoint. -type CreateCommand struct { - common.Base - manifest manifest.Data - - // required - EndpointName string // Can't shadow common.Base method Name(). - Version int - Bucket string - User string - SecretKey string - - // optional - Path common.OptionalString - Period common.OptionalUint - GzipLevel common.OptionalUint8 - Format common.OptionalString - FormatVersion common.OptionalUint - MessageType common.OptionalString - ResponseCondition common.OptionalString - TimestampFormat common.OptionalString - Placement common.OptionalString - CompressionCodec common.OptionalString -} - -// NewCreateCommand returns a usable command registered under the parent. -func NewCreateCommand(parent common.Registerer, globals *config.Data) *CreateCommand { - var c CreateCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("create", "Create a GCS logging endpoint on a Fastly service version").Alias("add") - - c.CmdClause.Flag("name", "The name of the GCS logging object. Used as a primary key for API access").Short('n').Required().StringVar(&c.EndpointName) - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Version) - c.CmdClause.Flag("user", "Your GCS service account email address. The client_email field in your service account authentication JSON").Required().StringVar(&c.User) - c.CmdClause.Flag("bucket", "The bucket of the GCS bucket").Required().StringVar(&c.Bucket) - c.CmdClause.Flag("secret-key", "Your GCS account secret key. The private_key field in your service account authentication JSON").Required().StringVar(&c.SecretKey) - - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("period", "How frequently log files are finalized so they can be available for reading (in seconds, default 3600)").Action(c.Period.Set).UintVar(&c.Period.Value) - c.CmdClause.Flag("path", "The path to upload logs to (default '/')").Action(c.Path.Set).StringVar(&c.Path.Value) - c.CmdClause.Flag("gzip-level", "What level of GZIP encoding to have when dumping logs (default 0, no compression)").Action(c.GzipLevel.Set).Uint8Var(&c.GzipLevel.Value) - c.CmdClause.Flag("format", "Apache style log formatting").Action(c.Format.Set).StringVar(&c.Format.Value) - c.CmdClause.Flag("format-version", "The version of the custom logging format used for the configured endpoint. Can be either 2 (the default, version 2 log format) or 1 (the version 1 log format). The logging call gets placed by default in vcl_log if format_version is set to 2 and in vcl_deliver if format_version is set to 1").Action(c.FormatVersion.Set).UintVar(&c.FormatVersion.Value) - c.CmdClause.Flag("message-type", "How the message should be formatted. One of: classic (default), loggly, logplex or blank").Action(c.MessageType.Set).StringVar(&c.MessageType.Value) - c.CmdClause.Flag("response-condition", "The name of an existing condition in the configured endpoint, or leave blank to always execute").Action(c.ResponseCondition.Set).StringVar(&c.ResponseCondition.Value) - c.CmdClause.Flag("timestamp-format", `strftime specified timestamp formatting (default "%Y-%m-%dT%H:%M:%S.000")`).Action(c.TimestampFormat.Set).StringVar(&c.TimestampFormat.Value) - c.CmdClause.Flag("placement", "Where in the generated VCL the logging call should be placed, overriding any format_version default. Can be none or waf_debug").Action(c.Placement.Set).StringVar(&c.Placement.Value) - c.CmdClause.Flag("compression-codec", `The codec used for compression of your logs. Valid values are zstd, snappy, and gzip. If the specified codec is "gzip", gzip_level will default to 3. To specify a different level, leave compression_codec blank and explicitly set the level using gzip_level. Specifying both compression_codec and gzip_level in the same API request will result in an error.`).Action(c.CompressionCodec.Set).StringVar(&c.CompressionCodec.Value) - - return &c -} - -// createInput transforms values parsed from CLI flags into an object to be used by the API client library. -func (c *CreateCommand) createInput() (*fastly.CreateGCSInput, error) { - var input fastly.CreateGCSInput - - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return nil, errors.ErrNoServiceID - } - - input.ServiceID = serviceID - input.ServiceVersion = c.Version - input.Name = c.EndpointName - input.Bucket = c.Bucket - input.User = c.User - input.SecretKey = c.SecretKey - - // The following blocks enforces the mutual exclusivity of the - // CompressionCodec and GzipLevel flags. - if c.CompressionCodec.WasSet && c.GzipLevel.WasSet { - return nil, fmt.Errorf("error parsing arguments: the --compression-codec flag is mutually exclusive with the --gzip-level flag") - } - - if c.Path.WasSet { - input.Path = c.Path.Value - } - - if c.Period.WasSet { - input.Period = c.Period.Value - } - - if c.Format.WasSet { - input.Format = c.Format.Value - } - - if c.FormatVersion.WasSet { - input.FormatVersion = c.FormatVersion.Value - } - - if c.GzipLevel.WasSet { - input.GzipLevel = c.GzipLevel.Value - } - - if c.ResponseCondition.WasSet { - input.ResponseCondition = c.ResponseCondition.Value - } - - if c.TimestampFormat.WasSet { - input.TimestampFormat = c.TimestampFormat.Value - } - - if c.MessageType.WasSet { - input.MessageType = c.MessageType.Value - } - - if c.Placement.WasSet { - input.Placement = c.Placement.Value - } - - if c.CompressionCodec.WasSet { - input.CompressionCodec = c.CompressionCodec.Value - } - - return &input, nil -} - -// Exec invokes the application logic for the command. -func (c *CreateCommand) Exec(in io.Reader, out io.Writer) error { - input, err := c.createInput() - if err != nil { - return err - } - - d, err := c.Globals.Client.CreateGCS(input) - if err != nil { - return err - } - - text.Success(out, "Created GCS logging endpoint %s (service %s version %d)", d.Name, d.ServiceID, d.ServiceVersion) - return nil -} diff --git a/pkg/logging/gcs/delete.go b/pkg/logging/gcs/delete.go deleted file mode 100644 index 8eb9e09a9..000000000 --- a/pkg/logging/gcs/delete.go +++ /dev/null @@ -1,50 +0,0 @@ -package gcs - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// DeleteCommand calls the Fastly API to delete a GCS logging endpoint. -type DeleteCommand struct { - common.Base - manifest manifest.Data - Input fastly.DeleteGCSInput -} - -// NewDeleteCommand returns a usable command registered under the parent. -func NewDeleteCommand(parent common.Registerer, globals *config.Data) *DeleteCommand { - var c DeleteCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("delete", "Delete a GCS logging endpoint on a Fastly service version").Alias("remove") - - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Input.ServiceVersion) - c.CmdClause.Flag("name", "The name of the GCS logging object").Short('n').Required().StringVar(&c.Input.Name) - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - - return &c -} - -// Exec invokes the application logic for the command. -func (c *DeleteCommand) Exec(in io.Reader, out io.Writer) error { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - c.Input.ServiceID = serviceID - - if err := c.Globals.Client.DeleteGCS(&c.Input); err != nil { - return err - } - - text.Success(out, "Deleted GCS logging endpoint %s (service %s version %d)", c.Input.Name, c.Input.ServiceID, c.Input.ServiceVersion) - return nil -} diff --git a/pkg/logging/gcs/describe.go b/pkg/logging/gcs/describe.go deleted file mode 100644 index d1d383a1b..000000000 --- a/pkg/logging/gcs/describe.go +++ /dev/null @@ -1,65 +0,0 @@ -package gcs - -import ( - "fmt" - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/go-fastly/v3/fastly" -) - -// DescribeCommand calls the Fastly API to describe a GCS logging endpoint. -type DescribeCommand struct { - common.Base - manifest manifest.Data - Input fastly.GetGCSInput -} - -// NewDescribeCommand returns a usable command registered under the parent. -func NewDescribeCommand(parent common.Registerer, globals *config.Data) *DescribeCommand { - var c DescribeCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("describe", "Show detailed information about a GCS logging endpoint on a Fastly service version").Alias("get") - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Input.ServiceVersion) - c.CmdClause.Flag("name", "The name of the GCS logging object").Short('n').Required().StringVar(&c.Input.Name) - return &c -} - -// Exec invokes the application logic for the command. -func (c *DescribeCommand) Exec(in io.Reader, out io.Writer) error { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - c.Input.ServiceID = serviceID - - gcs, err := c.Globals.Client.GetGCS(&c.Input) - if err != nil { - return err - } - - fmt.Fprintf(out, "Service ID: %s\n", gcs.ServiceID) - fmt.Fprintf(out, "Version: %d\n", gcs.ServiceVersion) - fmt.Fprintf(out, "Name: %s\n", gcs.Name) - fmt.Fprintf(out, "Bucket: %s\n", gcs.Bucket) - fmt.Fprintf(out, "User: %s\n", gcs.User) - fmt.Fprintf(out, "Secret key: %s\n", gcs.SecretKey) - fmt.Fprintf(out, "Path: %s\n", gcs.Path) - fmt.Fprintf(out, "Period: %d\n", gcs.Period) - fmt.Fprintf(out, "GZip level: %d\n", gcs.GzipLevel) - fmt.Fprintf(out, "Format: %s\n", gcs.Format) - fmt.Fprintf(out, "Format version: %d\n", gcs.FormatVersion) - fmt.Fprintf(out, "Response condition: %s\n", gcs.ResponseCondition) - fmt.Fprintf(out, "Message type: %s\n", gcs.MessageType) - fmt.Fprintf(out, "Timestamp format: %s\n", gcs.TimestampFormat) - fmt.Fprintf(out, "Placement: %s\n", gcs.Placement) - fmt.Fprintf(out, "Compression codec: %s\n", gcs.CompressionCodec) - - return nil -} diff --git a/pkg/logging/gcs/gcs_integration_test.go b/pkg/logging/gcs/gcs_integration_test.go deleted file mode 100644 index eecfef270..000000000 --- a/pkg/logging/gcs/gcs_integration_test.go +++ /dev/null @@ -1,433 +0,0 @@ -package gcs_test - -import ( - "bytes" - "errors" - "io" - "net/http" - "strings" - "testing" - - "github.com/fastly/cli/pkg/app" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/mock" - "github.com/fastly/cli/pkg/testutil" - "github.com/fastly/cli/pkg/update" - "github.com/fastly/go-fastly/v3/fastly" -) - -func TestGCSCreate(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "gcs", "create", "--service-id", "123", "--version", "1", "--name", "log", "--user", "foo@example.com", "--secret-key", "foo"}, - wantError: "error parsing arguments: required flag --bucket not provided", - }, - { - args: []string{"logging", "gcs", "create", "--service-id", "123", "--version", "1", "--name", "log", "--bucket", "log", "--secret-key", "foo"}, - wantError: "error parsing arguments: required flag --user not provided", - }, - { - args: []string{"logging", "gcs", "create", "--service-id", "123", "--version", "1", "--name", "log", "--bucket", "log", "--user", "foo@example.com"}, - wantError: "error parsing arguments: required flag --secret-key not provided", - }, - { - args: []string{"logging", "gcs", "create", "--service-id", "123", "--version", "1", "--name", "log", "--bucket", "log", "--user", "foo@example.com", "--secret-key", "foo", "--period", "86400"}, - api: mock.API{CreateGCSFn: createGCSOK}, - wantOutput: "Created GCS logging endpoint log (service 123 version 1)", - }, - { - args: []string{"logging", "gcs", "create", "--service-id", "123", "--version", "1", "--name", "log", "--bucket", "log", "--user", "foo@example.com", "--secret-key", "foo", "--period", "86400"}, - api: mock.API{CreateGCSFn: createGCSError}, - wantError: errTest.Error(), - }, - { - args: []string{"logging", "gcs", "create", "--service-id", "123", "--version", "1", "--name", "log", "--bucket", "log", "--user", "foo@example.com", "--secret-key", "foo", "--period", "86400", "--compression-codec", "zstd", "--gzip-level", "9"}, - wantError: "error parsing arguments: the --compression-codec flag is mutually exclusive with the --gzip-level flag", - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertStringContains(t, out.String(), testcase.wantOutput) - }) - } -} - -func TestGCSList(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "gcs", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListGCSsFn: listGCSsOK}, - wantOutput: listGCSsShortOutput, - }, - { - args: []string{"logging", "gcs", "list", "--service-id", "123", "--version", "1", "--verbose"}, - api: mock.API{ListGCSsFn: listGCSsOK}, - wantOutput: listGCSsVerboseOutput, - }, - { - args: []string{"logging", "gcs", "list", "--service-id", "123", "--version", "1", "-v"}, - api: mock.API{ListGCSsFn: listGCSsOK}, - wantOutput: listGCSsVerboseOutput, - }, - { - args: []string{"logging", "gcs", "--verbose", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListGCSsFn: listGCSsOK}, - wantOutput: listGCSsVerboseOutput, - }, - { - args: []string{"logging", "-v", "gcs", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListGCSsFn: listGCSsOK}, - wantOutput: listGCSsVerboseOutput, - }, - { - args: []string{"logging", "gcs", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListGCSsFn: listGCSsError}, - wantError: errTest.Error(), - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertString(t, testcase.wantOutput, out.String()) - }) - } -} - -func TestGCSDescribe(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "gcs", "describe", "--service-id", "123", "--version", "1"}, - wantError: "error parsing arguments: required flag --name not provided", - }, - { - args: []string{"logging", "gcs", "describe", "--service-id", "123", "--version", "1", "--name", "logs"}, - api: mock.API{GetGCSFn: getGCSError}, - wantError: errTest.Error(), - }, - { - args: []string{"logging", "gcs", "describe", "--service-id", "123", "--version", "1", "--name", "logs"}, - api: mock.API{GetGCSFn: getGCSOK}, - wantOutput: describeGCSOutput, - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertString(t, testcase.wantOutput, out.String()) - }) - } -} - -func TestGCSUpdate(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "gcs", "update", "--service-id", "123", "--version", "1", "--new-name", "log"}, - wantError: "error parsing arguments: required flag --name not provided", - }, - { - args: []string{"logging", "gcs", "update", "--service-id", "123", "--version", "1", "--name", "logs", "--new-name", "log"}, - api: mock.API{UpdateGCSFn: updateGCSError}, - wantError: errTest.Error(), - }, - { - args: []string{"logging", "gcs", "update", "--service-id", "123", "--version", "1", "--name", "logs", "--new-name", "log"}, - api: mock.API{UpdateGCSFn: updateGCSOK}, - wantOutput: "Updated GCS logging endpoint log (service 123 version 1)", - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertStringContains(t, out.String(), testcase.wantOutput) - }) - } -} - -func TestGCSDelete(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "gcs", "delete", "--service-id", "123", "--version", "1"}, - wantError: "error parsing arguments: required flag --name not provided", - }, - { - args: []string{"logging", "gcs", "delete", "--service-id", "123", "--version", "1", "--name", "logs"}, - api: mock.API{DeleteGCSFn: deleteGCSError}, - wantError: errTest.Error(), - }, - { - args: []string{"logging", "gcs", "delete", "--service-id", "123", "--version", "1", "--name", "logs"}, - api: mock.API{DeleteGCSFn: deleteGCSOK}, - wantOutput: "Deleted GCS logging endpoint logs (service 123 version 1)", - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertStringContains(t, out.String(), testcase.wantOutput) - }) - } -} - -var errTest = errors.New("fixture error") - -func createGCSOK(i *fastly.CreateGCSInput) (*fastly.GCS, error) { - return &fastly.GCS{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: i.Name, - }, nil -} - -func createGCSError(i *fastly.CreateGCSInput) (*fastly.GCS, error) { - return nil, errTest -} - -func listGCSsOK(i *fastly.ListGCSsInput) ([]*fastly.GCS, error) { - return []*fastly.GCS{ - { - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "logs", - Bucket: "my-logs", - User: "foo@example.com", - SecretKey: "-----BEGIN RSA PRIVATE KEY-----foo", - Path: "logs/", - Period: 3600, - GzipLevel: 0, - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - MessageType: "classic", - ResponseCondition: "Prevent default logging", - TimestampFormat: "%Y-%m-%dT%H:%M:%S.000", - Placement: "none", - CompressionCodec: "zstd", - }, - { - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "analytics", - Bucket: "analytics", - User: "foo@example.com", - SecretKey: "-----BEGIN RSA PRIVATE KEY-----foo", - Path: "logs/", - Period: 86400, - GzipLevel: 0, - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - MessageType: "classic", - ResponseCondition: "Prevent default logging", - TimestampFormat: "%Y-%m-%dT%H:%M:%S.000", - Placement: "none", - CompressionCodec: "zstd", - }, - }, nil -} - -func listGCSsError(i *fastly.ListGCSsInput) ([]*fastly.GCS, error) { - return nil, errTest -} - -var listGCSsShortOutput = strings.TrimSpace(` -SERVICE VERSION NAME -123 1 logs -123 1 analytics -`) + "\n" - -var listGCSsVerboseOutput = strings.TrimSpace(` -Fastly API token not provided -Fastly API endpoint: https://api.fastly.com -Service ID: 123 -Version: 1 - GCS 1/2 - Service ID: 123 - Version: 1 - Name: logs - Bucket: my-logs - User: foo@example.com - Secret key: -----BEGIN RSA PRIVATE KEY-----foo - Path: logs/ - Period: 3600 - GZip level: 0 - Format: %h %l %u %t "%r" %>s %b - Format version: 2 - Response condition: Prevent default logging - Message type: classic - Timestamp format: %Y-%m-%dT%H:%M:%S.000 - Placement: none - Compression codec: zstd - GCS 2/2 - Service ID: 123 - Version: 1 - Name: analytics - Bucket: analytics - User: foo@example.com - Secret key: -----BEGIN RSA PRIVATE KEY-----foo - Path: logs/ - Period: 86400 - GZip level: 0 - Format: %h %l %u %t "%r" %>s %b - Format version: 2 - Response condition: Prevent default logging - Message type: classic - Timestamp format: %Y-%m-%dT%H:%M:%S.000 - Placement: none - Compression codec: zstd -`) + "\n\n" - -func getGCSOK(i *fastly.GetGCSInput) (*fastly.GCS, error) { - return &fastly.GCS{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "logs", - Bucket: "my-logs", - User: "foo@example.com", - SecretKey: "-----BEGIN RSA PRIVATE KEY-----foo", - Path: "logs/", - Period: 3600, - GzipLevel: 0, - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - MessageType: "classic", - ResponseCondition: "Prevent default logging", - TimestampFormat: "%Y-%m-%dT%H:%M:%S.000", - Placement: "none", - CompressionCodec: "zstd", - }, nil -} - -func getGCSError(i *fastly.GetGCSInput) (*fastly.GCS, error) { - return nil, errTest -} - -var describeGCSOutput = strings.TrimSpace(` -Service ID: 123 -Version: 1 -Name: logs -Bucket: my-logs -User: foo@example.com -Secret key: -----BEGIN RSA PRIVATE KEY-----foo -Path: logs/ -Period: 3600 -GZip level: 0 -Format: %h %l %u %t "%r" %>s %b -Format version: 2 -Response condition: Prevent default logging -Message type: classic -Timestamp format: %Y-%m-%dT%H:%M:%S.000 -Placement: none -Compression codec: zstd -`) + "\n" - -func updateGCSOK(i *fastly.UpdateGCSInput) (*fastly.GCS, error) { - return &fastly.GCS{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "log", - Bucket: "logs", - User: "foo@example.com", - SecretKey: "-----BEGIN RSA PRIVATE KEY-----foo", - Path: "logs/", - Period: 3600, - GzipLevel: 0, - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - ResponseCondition: "Prevent default logging", - MessageType: "classic", - TimestampFormat: "%Y-%m-%dT%H:%M:%S.000", - Placement: "none", - CompressionCodec: "zstd", - }, nil -} - -func updateGCSError(i *fastly.UpdateGCSInput) (*fastly.GCS, error) { - return nil, errTest -} - -func deleteGCSOK(i *fastly.DeleteGCSInput) error { - return nil -} - -func deleteGCSError(i *fastly.DeleteGCSInput) error { - return errTest -} diff --git a/pkg/logging/gcs/gcs_test.go b/pkg/logging/gcs/gcs_test.go deleted file mode 100644 index a616aae44..000000000 --- a/pkg/logging/gcs/gcs_test.go +++ /dev/null @@ -1,221 +0,0 @@ -package gcs - -import ( - "testing" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/mock" - "github.com/fastly/cli/pkg/testutil" - "github.com/fastly/go-fastly/v3/fastly" -) - -func TestCreateGCSInput(t *testing.T) { - for _, testcase := range []struct { - name string - cmd *CreateCommand - want *fastly.CreateGCSInput - wantError string - }{ - { - name: "required values set flag serviceID", - cmd: createCommandRequired(), - want: &fastly.CreateGCSInput{ - ServiceID: "123", - ServiceVersion: 2, - Name: "log", - Bucket: "bucket", - User: "user", - SecretKey: "-----BEGIN PRIVATE KEY-----foo", - }, - }, - { - name: "all values set flag serviceID", - cmd: createCommandAll(), - want: &fastly.CreateGCSInput{ - ServiceID: "123", - ServiceVersion: 2, - Name: "log", - Bucket: "bucket", - User: "user", - SecretKey: "-----BEGIN PRIVATE KEY-----foo", - Path: "/logs", - Period: 3600, - FormatVersion: 2, - Format: `%h %l %u %t "%r" %>s %b`, - MessageType: "classic", - ResponseCondition: "Prevent default logging", - TimestampFormat: "%Y-%m-%dT%H:%M:%S.000", - Placement: "none", - CompressionCodec: "zstd"}, - }, - { - name: "error missing serviceID", - cmd: createCommandMissingServiceID(), - want: nil, - wantError: errors.ErrNoServiceID.Error(), - }, - } { - t.Run(testcase.name, func(t *testing.T) { - have, err := testcase.cmd.createInput() - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertEqual(t, testcase.want, have) - }) - } -} - -func TestUpdateGCSInput(t *testing.T) { - for _, testcase := range []struct { - name string - cmd *UpdateCommand - api mock.API - want *fastly.UpdateGCSInput - wantError string - }{ - { - name: "no updates", - cmd: updateCommandNoUpdates(), - api: mock.API{GetGCSFn: getGCSOK}, - want: &fastly.UpdateGCSInput{ - ServiceID: "123", - ServiceVersion: 2, - Name: "log", - }, - }, - { - name: "all values set flag serviceID", - cmd: updateCommandAll(), - api: mock.API{GetGCSFn: getGCSOK}, - want: &fastly.UpdateGCSInput{ - ServiceID: "123", - ServiceVersion: 2, - Name: "log", - NewName: fastly.String("new1"), - Bucket: fastly.String("new2"), - User: fastly.String("new3"), - SecretKey: fastly.String("new4"), - Path: fastly.String("new5"), - Period: fastly.Uint(3601), - FormatVersion: fastly.Uint(3), - GzipLevel: fastly.Uint8(0), - Format: fastly.String("new6"), - ResponseCondition: fastly.String("new7"), - TimestampFormat: fastly.String("new8"), - Placement: fastly.String("new9"), - MessageType: fastly.String("new10"), - CompressionCodec: fastly.String("new11"), - }, - }, - { - name: "error missing serviceID", - cmd: updateCommandMissingServiceID(), - want: nil, - wantError: errors.ErrNoServiceID.Error(), - }, - } { - t.Run(testcase.name, func(t *testing.T) { - testcase.cmd.Base.Globals.Client = testcase.api - - have, err := testcase.cmd.createInput() - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertEqual(t, testcase.want, have) - }) - } -} - -func createCommandRequired() *CreateCommand { - return &CreateCommand{ - manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, - EndpointName: "log", - Version: 2, - Bucket: "bucket", - User: "user", - SecretKey: "-----BEGIN PRIVATE KEY-----foo", - } -} - -func createCommandAll() *CreateCommand { - return &CreateCommand{ - manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, - EndpointName: "log", - Version: 2, - Bucket: "bucket", - User: "user", - SecretKey: "-----BEGIN PRIVATE KEY-----foo", - Path: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "/logs"}, - Period: common.OptionalUint{Optional: common.Optional{WasSet: true}, Value: 3600}, - Format: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: `%h %l %u %t "%r" %>s %b`}, - FormatVersion: common.OptionalUint{Optional: common.Optional{WasSet: true}, Value: 2}, - TimestampFormat: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "%Y-%m-%dT%H:%M:%S.000"}, - MessageType: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "classic"}, - ResponseCondition: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "Prevent default logging"}, - Placement: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "none"}, - CompressionCodec: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "zstd"}, - } -} - -func createCommandMissingServiceID() *CreateCommand { - res := createCommandAll() - res.manifest = manifest.Data{} - return res -} - -func updateCommandNoUpdates() *UpdateCommand { - return &UpdateCommand{ - Base: common.Base{Globals: &config.Data{Client: nil}}, - manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, - EndpointName: "log", - Version: 2, - } -} - -func updateCommandAll() *UpdateCommand { - return &UpdateCommand{ - Base: common.Base{Globals: &config.Data{Client: nil}}, - manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, - EndpointName: "log", - Version: 2, - NewName: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new1"}, - Bucket: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new2"}, - User: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new3"}, - SecretKey: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new4"}, - Path: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new5"}, - Period: common.OptionalUint{Optional: common.Optional{WasSet: true}, Value: 3601}, - GzipLevel: common.OptionalUint8{Optional: common.Optional{WasSet: true}, Value: 0}, - Format: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new6"}, - FormatVersion: common.OptionalUint{Optional: common.Optional{WasSet: true}, Value: 3}, - ResponseCondition: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new7"}, - TimestampFormat: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new8"}, - Placement: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new9"}, - MessageType: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new10"}, - CompressionCodec: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new11"}, - } -} - -func updateCommandMissingServiceID() *UpdateCommand { - res := updateCommandAll() - res.manifest = manifest.Data{} - return res -} - -func getGCSOK(i *fastly.GetGCSInput) (*fastly.GCS, error) { - return &fastly.GCS{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "logs", - Bucket: "bucket", - User: "user", - SecretKey: "-----BEGIN PRIVATE KEY-----foo", - Path: "/logs", - Period: 3600, - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - MessageType: "classic", - ResponseCondition: "Prevent default logging", - TimestampFormat: "%Y-%m-%dT%H:%M:%S.000", - Placement: "none", - CompressionCodec: "zstd", - }, nil -} diff --git a/pkg/logging/gcs/list.go b/pkg/logging/gcs/list.go deleted file mode 100644 index c227379f5..000000000 --- a/pkg/logging/gcs/list.go +++ /dev/null @@ -1,81 +0,0 @@ -package gcs - -import ( - "fmt" - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// ListCommand calls the Fastly API to list GCS logging endpoints. -type ListCommand struct { - common.Base - manifest manifest.Data - Input fastly.ListGCSsInput -} - -// NewListCommand returns a usable command registered under the parent. -func NewListCommand(parent common.Registerer, globals *config.Data) *ListCommand { - var c ListCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("list", "List GCS endpoints on a Fastly service version") - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Input.ServiceVersion) - return &c -} - -// Exec invokes the application logic for the command. -func (c *ListCommand) Exec(in io.Reader, out io.Writer) error { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - c.Input.ServiceID = serviceID - - gcss, err := c.Globals.Client.ListGCSs(&c.Input) - if err != nil { - return err - } - - if !c.Globals.Verbose() { - tw := text.NewTable(out) - tw.AddHeader("SERVICE", "VERSION", "NAME") - for _, gcs := range gcss { - tw.AddLine(gcs.ServiceID, gcs.ServiceVersion, gcs.Name) - } - tw.Print() - return nil - } - - fmt.Fprintf(out, "Service ID: %s\n", c.Input.ServiceID) - fmt.Fprintf(out, "Version: %d\n", c.Input.ServiceVersion) - for i, gcs := range gcss { - fmt.Fprintf(out, "\tGCS %d/%d\n", i+1, len(gcss)) - fmt.Fprintf(out, "\t\tService ID: %s\n", gcs.ServiceID) - fmt.Fprintf(out, "\t\tVersion: %d\n", gcs.ServiceVersion) - fmt.Fprintf(out, "\t\tName: %s\n", gcs.Name) - fmt.Fprintf(out, "\t\tBucket: %s\n", gcs.Bucket) - fmt.Fprintf(out, "\t\tUser: %s\n", gcs.User) - fmt.Fprintf(out, "\t\tSecret key: %s\n", gcs.SecretKey) - fmt.Fprintf(out, "\t\tPath: %s\n", gcs.Path) - fmt.Fprintf(out, "\t\tPeriod: %d\n", gcs.Period) - fmt.Fprintf(out, "\t\tGZip level: %d\n", gcs.GzipLevel) - fmt.Fprintf(out, "\t\tFormat: %s\n", gcs.Format) - fmt.Fprintf(out, "\t\tFormat version: %d\n", gcs.FormatVersion) - fmt.Fprintf(out, "\t\tResponse condition: %s\n", gcs.ResponseCondition) - fmt.Fprintf(out, "\t\tMessage type: %s\n", gcs.MessageType) - fmt.Fprintf(out, "\t\tTimestamp format: %s\n", gcs.TimestampFormat) - fmt.Fprintf(out, "\t\tPlacement: %s\n", gcs.Placement) - fmt.Fprintf(out, "\t\tCompression codec: %s\n", gcs.CompressionCodec) - } - fmt.Fprintln(out) - - return nil -} diff --git a/pkg/logging/gcs/root.go b/pkg/logging/gcs/root.go deleted file mode 100644 index 9d99630e3..000000000 --- a/pkg/logging/gcs/root.go +++ /dev/null @@ -1,28 +0,0 @@ -package gcs - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/config" -) - -// RootCommand is the parent command for all subcommands in this package. -// It should be installed under the primary root command. -type RootCommand struct { - common.Base - // no flags -} - -// NewRootCommand returns a new command registered in the parent. -func NewRootCommand(parent common.Registerer, globals *config.Data) *RootCommand { - var c RootCommand - c.Globals = globals - c.CmdClause = parent.Command("gcs", "Manipulate Fastly service version GCS logging endpoints") - return &c -} - -// Exec implements the command interface. -func (c *RootCommand) Exec(in io.Reader, out io.Writer) error { - panic("unreachable") -} diff --git a/pkg/logging/gcs/update.go b/pkg/logging/gcs/update.go deleted file mode 100644 index 9af52e85d..000000000 --- a/pkg/logging/gcs/update.go +++ /dev/null @@ -1,158 +0,0 @@ -package gcs - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// UpdateCommand calls the Fastly API to update a GCS logging endpoint. -type UpdateCommand struct { - common.Base - manifest manifest.Data - - // required - EndpointName string // Can't shadow common.Base method Name(). - Version int - - // optional - NewName common.OptionalString - Bucket common.OptionalString - User common.OptionalString - SecretKey common.OptionalString - Path common.OptionalString - Period common.OptionalUint - FormatVersion common.OptionalUint - GzipLevel common.OptionalUint8 - Format common.OptionalString - ResponseCondition common.OptionalString - TimestampFormat common.OptionalString - MessageType common.OptionalString - Placement common.OptionalString - CompressionCodec common.OptionalString -} - -// NewUpdateCommand returns a usable command registered under the parent. -func NewUpdateCommand(parent common.Registerer, globals *config.Data) *UpdateCommand { - var c UpdateCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - - c.CmdClause = parent.Command("update", "Update a GCS logging endpoint on a Fastly service version") - - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Version) - c.CmdClause.Flag("name", "The name of the GCS logging object").Short('n').Required().StringVar(&c.EndpointName) - - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("new-name", "New name of the GCS logging object").Action(c.NewName.Set).StringVar(&c.NewName.Value) - c.CmdClause.Flag("bucket", "The bucket of the GCS bucket").Action(c.Bucket.Set).StringVar(&c.Bucket.Value) - c.CmdClause.Flag("user", "Your GCS service account email address. The client_email field in your service account authentication JSON").Action(c.User.Set).StringVar(&c.User.Value) - c.CmdClause.Flag("secret-key", "Your GCS account secret key. The private_key field in your service account authentication JSON").Action(c.SecretKey.Set).StringVar(&c.SecretKey.Value) - c.CmdClause.Flag("path", "The path to upload logs to (default '/')").Action(c.Path.Set).StringVar(&c.Path.Value) - c.CmdClause.Flag("period", "How frequently log files are finalized so they can be available for reading (in seconds, default 3600)").Action(c.Period.Set).UintVar(&c.Period.Value) - c.CmdClause.Flag("format-version", "The version of the custom logging format used for the configured endpoint. Can be either 2 (the default, version 2 log format) or 1 (the version 1 log format). The logging call gets placed by default in vcl_log if format_version is set to 2 and in vcl_deliver if format_version is set to 1").Action(c.FormatVersion.Set).UintVar(&c.FormatVersion.Value) - c.CmdClause.Flag("gzip-level", "What level of GZIP encoding to have when dumping logs (default 0, no compression)").Action(c.GzipLevel.Set).Uint8Var(&c.GzipLevel.Value) - c.CmdClause.Flag("format", "Apache style log formatting").Action(c.Format.Set).StringVar(&c.Format.Value) - c.CmdClause.Flag("response-condition", "The name of an existing condition in the configured endpoint, or leave blank to always execute").Action(c.ResponseCondition.Set).StringVar(&c.ResponseCondition.Value) - c.CmdClause.Flag("timestamp-format", `strftime specified timestamp formatting (default "%Y-%m-%dT%H:%M:%S.000")`).Action(c.TimestampFormat.Set).StringVar(&c.TimestampFormat.Value) - c.CmdClause.Flag("message-type", "How the message should be formatted. One of: classic (default), loggly, logplex or blank").Action(c.MessageType.Set).StringVar(&c.MessageType.Value) - c.CmdClause.Flag("placement", "Where in the generated VCL the logging call should be placed, overriding any format_version default. Can be none or waf_debug").Action(c.Placement.Set).StringVar(&c.Placement.Value) - c.CmdClause.Flag("compression-codec", `The codec used for compression of your logs. Valid values are zstd, snappy, and gzip. If the specified codec is "gzip", gzip_level will default to 3. To specify a different level, leave compression_codec blank and explicitly set the level using gzip_level. Specifying both compression_codec and gzip_level in the same API request will result in an error.`).Action(c.CompressionCodec.Set).StringVar(&c.CompressionCodec.Value) - - return &c -} - -// createInput transforms values parsed from CLI flags into an object to be used by the API client library. -func (c *UpdateCommand) createInput() (*fastly.UpdateGCSInput, error) { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return nil, errors.ErrNoServiceID - } - - input := fastly.UpdateGCSInput{ - ServiceID: serviceID, - ServiceVersion: c.Version, - Name: c.EndpointName, - } - - // Set new values if set by user. - if c.NewName.WasSet { - input.NewName = fastly.String(c.NewName.Value) - } - - if c.Bucket.WasSet { - input.Bucket = fastly.String(c.Bucket.Value) - } - - if c.User.WasSet { - input.User = fastly.String(c.User.Value) - } - - if c.SecretKey.WasSet { - input.SecretKey = fastly.String(c.SecretKey.Value) - } - - if c.Path.WasSet { - input.Path = fastly.String(c.Path.Value) - } - - if c.Period.WasSet { - input.Period = fastly.Uint(c.Period.Value) - } - - if c.FormatVersion.WasSet { - input.FormatVersion = fastly.Uint(c.FormatVersion.Value) - } - - if c.GzipLevel.WasSet { - input.GzipLevel = fastly.Uint8(c.GzipLevel.Value) - } - - if c.Format.WasSet { - input.Format = fastly.String(c.Format.Value) - } - - if c.ResponseCondition.WasSet { - input.ResponseCondition = fastly.String(c.ResponseCondition.Value) - } - - if c.TimestampFormat.WasSet { - input.TimestampFormat = fastly.String(c.TimestampFormat.Value) - } - - if c.MessageType.WasSet { - input.MessageType = fastly.String(c.MessageType.Value) - } - - if c.Placement.WasSet { - input.Placement = fastly.String(c.Placement.Value) - } - - if c.CompressionCodec.WasSet { - input.CompressionCodec = fastly.String(c.CompressionCodec.Value) - } - - return &input, nil -} - -// Exec invokes the application logic for the command. -func (c *UpdateCommand) Exec(in io.Reader, out io.Writer) error { - input, err := c.createInput() - if err != nil { - return err - } - - gcs, err := c.Globals.Client.UpdateGCS(input) - if err != nil { - return err - } - - text.Success(out, "Updated GCS logging endpoint %s (service %s version %d)", gcs.Name, gcs.ServiceID, gcs.ServiceVersion) - return nil -} diff --git a/pkg/logging/googlepubsub/create.go b/pkg/logging/googlepubsub/create.go deleted file mode 100644 index 44822d703..000000000 --- a/pkg/logging/googlepubsub/create.go +++ /dev/null @@ -1,108 +0,0 @@ -package googlepubsub - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// CreateCommand calls the Fastly API to create a Google Cloud Pub/Sub logging endpoint. -type CreateCommand struct { - common.Base - manifest manifest.Data - - // required - EndpointName string // Can't shadow common.Base method Name(). - Version int - User string - SecretKey string - Topic string - ProjectID string - - // optional - Format common.OptionalString - FormatVersion common.OptionalUint - Placement common.OptionalString - ResponseCondition common.OptionalString -} - -// NewCreateCommand returns a usable command registered under the parent. -func NewCreateCommand(parent common.Registerer, globals *config.Data) *CreateCommand { - var c CreateCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("create", "Create a Google Cloud Pub/Sub logging endpoint on a Fastly service version").Alias("add") - - c.CmdClause.Flag("name", "The name of the Google Cloud Pub/Sub logging object. Used as a primary key for API access").Short('n').Required().StringVar(&c.EndpointName) - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Version) - c.CmdClause.Flag("user", "Your Google Cloud Platform service account email address. The client_email field in your service account authentication JSON").Required().StringVar(&c.User) - c.CmdClause.Flag("secret-key", "Your Google Cloud Platform account secret key. The private_key field in your service account authentication JSON").Required().StringVar(&c.SecretKey) - c.CmdClause.Flag("topic", "The Google Cloud Pub/Sub topic to which logs will be published").Required().StringVar(&c.Topic) - c.CmdClause.Flag("project-id", "The ID of your Google Cloud Platform project").Required().StringVar(&c.ProjectID) - - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("format", "Apache style log formatting").Action(c.Format.Set).StringVar(&c.Format.Value) - c.CmdClause.Flag("format-version", "The version of the custom logging format used for the configured endpoint. Can be either 2 (default) or 1").Action(c.FormatVersion.Set).UintVar(&c.FormatVersion.Value) - c.CmdClause.Flag("placement", "Where in the generated VCL the logging call should be placed, overriding any format_version default. Can be none or waf_debug. This field is not required and has no default value").Action(c.Placement.Set).StringVar(&c.Placement.Value) - c.CmdClause.Flag("response-condition", "The name of an existing condition in the configured endpoint, or leave blank to always execute").Action(c.ResponseCondition.Set).StringVar(&c.ResponseCondition.Value) - - return &c -} - -// createInput transforms values parsed from CLI flags into an object to be used by the API client library. -func (c *CreateCommand) createInput() (*fastly.CreatePubsubInput, error) { - var input fastly.CreatePubsubInput - - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return nil, errors.ErrNoServiceID - } - - input.ServiceID = serviceID - input.ServiceVersion = c.Version - input.Name = c.EndpointName - input.User = c.User - input.SecretKey = c.SecretKey - input.Topic = c.Topic - input.ProjectID = c.ProjectID - - if c.Format.WasSet { - input.Format = c.Format.Value - } - - if c.FormatVersion.WasSet { - input.FormatVersion = c.FormatVersion.Value - } - - if c.ResponseCondition.WasSet { - input.ResponseCondition = c.ResponseCondition.Value - } - - if c.Placement.WasSet { - input.Placement = c.Placement.Value - } - - return &input, nil -} - -// Exec invokes the application logic for the command. -func (c *CreateCommand) Exec(in io.Reader, out io.Writer) error { - input, err := c.createInput() - if err != nil { - return err - } - - d, err := c.Globals.Client.CreatePubsub(input) - if err != nil { - return err - } - - text.Success(out, "Created Google Cloud Pub/Sub logging endpoint %s (service %s version %d)", d.Name, d.ServiceID, d.ServiceVersion) - return nil -} diff --git a/pkg/logging/googlepubsub/delete.go b/pkg/logging/googlepubsub/delete.go deleted file mode 100644 index 1f33bad50..000000000 --- a/pkg/logging/googlepubsub/delete.go +++ /dev/null @@ -1,51 +0,0 @@ -package googlepubsub - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// DeleteCommand calls the Fastly API to delete a Google Cloud Pub/Sub logging endpoint. -type DeleteCommand struct { - common.Base - manifest manifest.Data - Input fastly.DeletePubsubInput -} - -// NewDeleteCommand returns a usable command registered under the parent. -func NewDeleteCommand(parent common.Registerer, globals *config.Data) *DeleteCommand { - var c DeleteCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("delete", "Delete a Google Cloud Pub/Sub logging endpoint on a Fastly service version").Alias("remove") - - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Input.ServiceVersion) - c.CmdClause.Flag("name", "The name of the Google Cloud Pub/Sub logging object").Short('n').Required().StringVar(&c.Input.Name) - - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - - return &c -} - -// Exec invokes the application logic for the command. -func (c *DeleteCommand) Exec(in io.Reader, out io.Writer) error { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - c.Input.ServiceID = serviceID - - if err := c.Globals.Client.DeletePubsub(&c.Input); err != nil { - return err - } - - text.Success(out, "Deleted Google Cloud Pub/Sub logging endpoint %s (service %s version %d)", c.Input.Name, c.Input.ServiceID, c.Input.ServiceVersion) - return nil -} diff --git a/pkg/logging/googlepubsub/describe.go b/pkg/logging/googlepubsub/describe.go deleted file mode 100644 index dcb087092..000000000 --- a/pkg/logging/googlepubsub/describe.go +++ /dev/null @@ -1,60 +0,0 @@ -package googlepubsub - -import ( - "fmt" - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/go-fastly/v3/fastly" -) - -// DescribeCommand calls the Fastly API to describe a Google Cloud Pub/Sub logging endpoint. -type DescribeCommand struct { - common.Base - manifest manifest.Data - Input fastly.GetPubsubInput -} - -// NewDescribeCommand returns a usable command registered under the parent. -func NewDescribeCommand(parent common.Registerer, globals *config.Data) *DescribeCommand { - var c DescribeCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("describe", "Show detailed information about a Google Cloud Pub/Sub logging endpoint on a Fastly service version").Alias("get") - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Input.ServiceVersion) - c.CmdClause.Flag("name", "The name of the Google Cloud Pub/Sub logging object").Short('n').Required().StringVar(&c.Input.Name) - return &c -} - -// Exec invokes the application logic for the command. -func (c *DescribeCommand) Exec(in io.Reader, out io.Writer) error { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - c.Input.ServiceID = serviceID - - googlepubsub, err := c.Globals.Client.GetPubsub(&c.Input) - if err != nil { - return err - } - - fmt.Fprintf(out, "Service ID: %s\n", googlepubsub.ServiceID) - fmt.Fprintf(out, "Version: %d\n", googlepubsub.ServiceVersion) - fmt.Fprintf(out, "Name: %s\n", googlepubsub.Name) - fmt.Fprintf(out, "User: %s\n", googlepubsub.User) - fmt.Fprintf(out, "Secret key: %s\n", googlepubsub.SecretKey) - fmt.Fprintf(out, "Project ID: %s\n", googlepubsub.ProjectID) - fmt.Fprintf(out, "Topic: %s\n", googlepubsub.Topic) - fmt.Fprintf(out, "Format: %s\n", googlepubsub.Format) - fmt.Fprintf(out, "Format version: %d\n", googlepubsub.FormatVersion) - fmt.Fprintf(out, "Response condition: %s\n", googlepubsub.ResponseCondition) - fmt.Fprintf(out, "Placement: %s\n", googlepubsub.Placement) - - return nil -} diff --git a/pkg/logging/googlepubsub/googlepubsub_integration_test.go b/pkg/logging/googlepubsub/googlepubsub_integration_test.go deleted file mode 100644 index 4a632ab2e..000000000 --- a/pkg/logging/googlepubsub/googlepubsub_integration_test.go +++ /dev/null @@ -1,406 +0,0 @@ -package googlepubsub_test - -import ( - "bytes" - "errors" - "io" - "net/http" - "strings" - "testing" - - "github.com/fastly/cli/pkg/app" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/mock" - "github.com/fastly/cli/pkg/testutil" - "github.com/fastly/cli/pkg/update" - "github.com/fastly/go-fastly/v3/fastly" -) - -func TestGooglePubSubCreate(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "googlepubsub", "create", "--service-id", "123", "--version", "1", "--name", "log", "--secret-key", "secret", "--project-id", "project", "--topic", "topic"}, - wantError: "error parsing arguments: required flag --user not provided", - }, - { - args: []string{"logging", "googlepubsub", "create", "--service-id", "123", "--version", "1", "--name", "log", "--user", "user@example.com", "--project-id", "project", "--topic", "topic"}, - wantError: "error parsing arguments: required flag --secret-key not provided", - }, - { - args: []string{"logging", "googlepubsub", "create", "--service-id", "123", "--version", "1", "--name", "log", "--user", "user@example.com", "--secret-key", "secret", "--topic", "topic"}, - wantError: "error parsing arguments: required flag --project-id not provided", - }, - { - args: []string{"logging", "googlepubsub", "create", "--service-id", "123", "--version", "1", "--name", "log", "--user", "user@example.com", "--secret-key", "secret", "--project-id", "project"}, - wantError: "error parsing arguments: required flag --topic not provided", - }, - { - args: []string{"logging", "googlepubsub", "create", "--service-id", "123", "--version", "1", "--name", "log", "--user", "user@example.com", "--secret-key", "secret", "--project-id", "project", "--topic", "topic"}, - api: mock.API{CreatePubsubFn: createGooglePubSubOK}, - wantOutput: "Created Google Cloud Pub/Sub logging endpoint log (service 123 version 1)", - }, - { - args: []string{"logging", "googlepubsub", "create", "--service-id", "123", "--version", "1", "--name", "log", "--user", "user@example.com", "--secret-key", "secret", "--project-id", "project", "--topic", "topic"}, - api: mock.API{CreatePubsubFn: createGooglePubSubError}, - wantError: errTest.Error(), - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertStringContains(t, out.String(), testcase.wantOutput) - }) - } -} - -func TestGooglePubSubList(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "googlepubsub", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListPubsubsFn: listGooglePubSubsOK}, - wantOutput: listGooglePubSubsShortOutput, - }, - { - args: []string{"logging", "googlepubsub", "list", "--service-id", "123", "--version", "1", "--verbose"}, - api: mock.API{ListPubsubsFn: listGooglePubSubsOK}, - wantOutput: listGooglePubSubsVerboseOutput, - }, - { - args: []string{"logging", "googlepubsub", "list", "--service-id", "123", "--version", "1", "-v"}, - api: mock.API{ListPubsubsFn: listGooglePubSubsOK}, - wantOutput: listGooglePubSubsVerboseOutput, - }, - { - args: []string{"logging", "googlepubsub", "--verbose", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListPubsubsFn: listGooglePubSubsOK}, - wantOutput: listGooglePubSubsVerboseOutput, - }, - { - args: []string{"logging", "-v", "googlepubsub", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListPubsubsFn: listGooglePubSubsOK}, - wantOutput: listGooglePubSubsVerboseOutput, - }, - { - args: []string{"logging", "googlepubsub", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListPubsubsFn: listGooglePubSubsError}, - wantError: errTest.Error(), - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertString(t, testcase.wantOutput, out.String()) - }) - } -} - -func TestGooglePubSubDescribe(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "googlepubsub", "describe", "--service-id", "123", "--version", "1"}, - wantError: "error parsing arguments: required flag --name not provided", - }, - { - args: []string{"logging", "googlepubsub", "describe", "--service-id", "123", "--version", "1", "--name", "logs"}, - api: mock.API{GetPubsubFn: getGooglePubSubError}, - wantError: errTest.Error(), - }, - { - args: []string{"logging", "googlepubsub", "describe", "--service-id", "123", "--version", "1", "--name", "logs"}, - api: mock.API{GetPubsubFn: getGooglePubSubOK}, - wantOutput: describeGooglePubSubOutput, - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertString(t, testcase.wantOutput, out.String()) - }) - } -} - -func TestGooglePubSubUpdate(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "googlepubsub", "update", "--service-id", "123", "--version", "1", "--new-name", "log"}, - wantError: "error parsing arguments: required flag --name not provided", - }, - { - args: []string{"logging", "googlepubsub", "update", "--service-id", "123", "--version", "1", "--name", "logs", "--new-name", "log"}, - api: mock.API{UpdatePubsubFn: updateGooglePubSubError}, - wantError: errTest.Error(), - }, - { - args: []string{"logging", "googlepubsub", "update", "--service-id", "123", "--version", "1", "--name", "logs", "--new-name", "log"}, - api: mock.API{UpdatePubsubFn: updateGooglePubSubOK}, - wantOutput: "Updated Google Cloud Pub/Sub logging endpoint log (service 123 version 1)", - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertStringContains(t, out.String(), testcase.wantOutput) - }) - } -} - -func TestGooglePubSubDelete(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "googlepubsub", "delete", "--service-id", "123", "--version", "1"}, - wantError: "error parsing arguments: required flag --name not provided", - }, - { - args: []string{"logging", "googlepubsub", "delete", "--service-id", "123", "--version", "1", "--name", "logs"}, - api: mock.API{DeletePubsubFn: deleteGooglePubSubError}, - wantError: errTest.Error(), - }, - { - args: []string{"logging", "googlepubsub", "delete", "--service-id", "123", "--version", "1", "--name", "logs"}, - api: mock.API{DeletePubsubFn: deleteGooglePubSubOK}, - wantOutput: "Deleted Google Cloud Pub/Sub logging endpoint logs (service 123 version 1)", - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertStringContains(t, out.String(), testcase.wantOutput) - }) - } -} - -var errTest = errors.New("fixture error") - -func createGooglePubSubOK(i *fastly.CreatePubsubInput) (*fastly.Pubsub, error) { - return &fastly.Pubsub{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "log", - Topic: "topic", - User: "user", - SecretKey: "secret", - ProjectID: "project", - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - ResponseCondition: "Prevent default logging", - Placement: "none", - }, nil -} - -func createGooglePubSubError(i *fastly.CreatePubsubInput) (*fastly.Pubsub, error) { - return nil, errTest -} - -func listGooglePubSubsOK(i *fastly.ListPubsubsInput) ([]*fastly.Pubsub, error) { - return []*fastly.Pubsub{ - { - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "logs", - User: "user@example.com", - SecretKey: "secret", - ProjectID: "project", - Topic: "topic", - ResponseCondition: "Prevent default logging", - Format: `%h %l %u %t "%r" %>s %b`, - Placement: "none", - FormatVersion: 2, - }, - { - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "analytics", - User: "user@example.com", - SecretKey: "secret", - ProjectID: "project", - Topic: "analytics", - Placement: "none", - ResponseCondition: "Prevent default logging", - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - }, - }, nil -} - -func listGooglePubSubsError(i *fastly.ListPubsubsInput) ([]*fastly.Pubsub, error) { - return nil, errTest -} - -var listGooglePubSubsShortOutput = strings.TrimSpace(` -SERVICE VERSION NAME -123 1 logs -123 1 analytics -`) + "\n" - -var listGooglePubSubsVerboseOutput = strings.TrimSpace(` -Fastly API token not provided -Fastly API endpoint: https://api.fastly.com -Service ID: 123 -Version: 1 - Google Cloud Pub/Sub 1/2 - Service ID: 123 - Version: 1 - Name: logs - User: user@example.com - Secret key: secret - Project ID: project - Topic: topic - Format: %h %l %u %t "%r" %>s %b - Format version: 2 - Response condition: Prevent default logging - Placement: none - Google Cloud Pub/Sub 2/2 - Service ID: 123 - Version: 1 - Name: analytics - User: user@example.com - Secret key: secret - Project ID: project - Topic: analytics - Format: %h %l %u %t "%r" %>s %b - Format version: 2 - Response condition: Prevent default logging - Placement: none -`) + "\n\n" - -func getGooglePubSubOK(i *fastly.GetPubsubInput) (*fastly.Pubsub, error) { - return &fastly.Pubsub{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "logs", - Topic: "topic", - User: "user@example.com", - SecretKey: "secret", - ProjectID: "project", - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - ResponseCondition: "Prevent default logging", - Placement: "none", - }, nil -} - -func getGooglePubSubError(i *fastly.GetPubsubInput) (*fastly.Pubsub, error) { - return nil, errTest -} - -var describeGooglePubSubOutput = strings.TrimSpace(` -Service ID: 123 -Version: 1 -Name: logs -User: user@example.com -Secret key: secret -Project ID: project -Topic: topic -Format: %h %l %u %t "%r" %>s %b -Format version: 2 -Response condition: Prevent default logging -Placement: none -`) + "\n" - -func updateGooglePubSubOK(i *fastly.UpdatePubsubInput) (*fastly.Pubsub, error) { - return &fastly.Pubsub{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "log", - Topic: "topic", - User: "user@example.com", - SecretKey: "secret", - ProjectID: "project", - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - ResponseCondition: "Prevent default logging", - Placement: "none", - }, nil -} - -func updateGooglePubSubError(i *fastly.UpdatePubsubInput) (*fastly.Pubsub, error) { - return nil, errTest -} - -func deleteGooglePubSubOK(i *fastly.DeletePubsubInput) error { - return nil -} - -func deleteGooglePubSubError(i *fastly.DeletePubsubInput) error { - return errTest -} diff --git a/pkg/logging/googlepubsub/googlepubsub_test.go b/pkg/logging/googlepubsub/googlepubsub_test.go deleted file mode 100644 index 6c8a57448..000000000 --- a/pkg/logging/googlepubsub/googlepubsub_test.go +++ /dev/null @@ -1,202 +0,0 @@ -package googlepubsub - -import ( - "testing" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/mock" - "github.com/fastly/cli/pkg/testutil" - "github.com/fastly/go-fastly/v3/fastly" -) - -func TestCreateGooglePubSubInput(t *testing.T) { - for _, testcase := range []struct { - name string - cmd *CreateCommand - want *fastly.CreatePubsubInput - wantError string - }{ - { - name: "required values set flag serviceID", - cmd: createCommandRequired(), - want: &fastly.CreatePubsubInput{ - ServiceID: "123", - ServiceVersion: 2, - Name: "log", - User: "user@example.com", - SecretKey: "secret", - ProjectID: "project", - Topic: "topic", - }, - }, - { - name: "all values set flag serviceID", - cmd: createCommandAll(), - want: &fastly.CreatePubsubInput{ - ServiceID: "123", - ServiceVersion: 2, - Name: "logs", - Topic: "topic", - User: "user@example.com", - SecretKey: "secret", - ProjectID: "project", - FormatVersion: 2, - Format: `%h %l %u %t "%r" %>s %b`, - ResponseCondition: "Prevent default logging", - Placement: "none", - }, - }, - { - name: "error missing serviceID", - cmd: createCommandMissingServiceID(), - want: nil, - wantError: errors.ErrNoServiceID.Error(), - }, - } { - t.Run(testcase.name, func(t *testing.T) { - have, err := testcase.cmd.createInput() - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertEqual(t, testcase.want, have) - }) - } -} - -func TestUpdateGooglePubSubInput(t *testing.T) { - for _, testcase := range []struct { - name string - cmd *UpdateCommand - api mock.API - want *fastly.UpdatePubsubInput - wantError string - }{ - { - name: "all values set flag serviceID", - cmd: updateCommandAll(), - api: mock.API{GetPubsubFn: getGooglePubSubOK}, - want: &fastly.UpdatePubsubInput{ - ServiceID: "123", - ServiceVersion: 2, - Name: "log", - NewName: fastly.String("new1"), - User: fastly.String("new2"), - SecretKey: fastly.String("new3"), - ProjectID: fastly.String("new4"), - Topic: fastly.String("new5"), - Placement: fastly.String("new6"), - Format: fastly.String("new7"), - FormatVersion: fastly.Uint(3), - ResponseCondition: fastly.String("new8"), - }, - }, - { - name: "no updates", - cmd: updateCommandNoUpdates(), - api: mock.API{GetPubsubFn: getGooglePubSubOK}, - want: &fastly.UpdatePubsubInput{ - ServiceID: "123", - ServiceVersion: 2, - Name: "log", - }, - }, - { - name: "error missing serviceID", - cmd: updateCommandMissingServiceID(), - want: nil, - wantError: errors.ErrNoServiceID.Error(), - }, - } { - t.Run(testcase.name, func(t *testing.T) { - testcase.cmd.Base.Globals.Client = testcase.api - - have, err := testcase.cmd.createInput() - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertEqual(t, testcase.want, have) - }) - } -} - -func createCommandRequired() *CreateCommand { - return &CreateCommand{ - manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, - EndpointName: "log", - Version: 2, - User: "user@example.com", - SecretKey: "secret", - ProjectID: "project", - Topic: "topic", - } -} - -func createCommandAll() *CreateCommand { - return &CreateCommand{ - manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, - EndpointName: "logs", - Version: 2, - User: "user@example.com", - ProjectID: "project", - Topic: "topic", - SecretKey: "secret", - Format: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: `%h %l %u %t "%r" %>s %b`}, - FormatVersion: common.OptionalUint{Optional: common.Optional{WasSet: true}, Value: 2}, - ResponseCondition: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "Prevent default logging"}, - Placement: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "none"}, - } -} - -func createCommandMissingServiceID() *CreateCommand { - res := createCommandAll() - res.manifest = manifest.Data{} - return res -} - -func updateCommandNoUpdates() *UpdateCommand { - return &UpdateCommand{ - Base: common.Base{Globals: &config.Data{Client: nil}}, - manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, - EndpointName: "log", - Version: 2, - } -} - -func updateCommandAll() *UpdateCommand { - return &UpdateCommand{ - Base: common.Base{Globals: &config.Data{Client: nil}}, - manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, - EndpointName: "log", - Version: 2, - NewName: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new1"}, - User: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new2"}, - SecretKey: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new3"}, - ProjectID: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new4"}, - Topic: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new5"}, - Placement: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new6"}, - Format: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new7"}, - FormatVersion: common.OptionalUint{Optional: common.Optional{WasSet: true}, Value: 3}, - ResponseCondition: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new8"}, - } -} - -func updateCommandMissingServiceID() *UpdateCommand { - res := updateCommandAll() - res.manifest = manifest.Data{} - return res -} - -func getGooglePubSubOK(i *fastly.GetPubsubInput) (*fastly.Pubsub, error) { - return &fastly.Pubsub{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "log", - ResponseCondition: "Prevent default logging", - Format: `%h %l %u %t "%r" %>s %b`, - User: "user@example.com", - SecretKey: "secret", - ProjectID: "project", - Topic: "topic", - Placement: "none", - FormatVersion: 2, - }, nil -} diff --git a/pkg/logging/googlepubsub/list.go b/pkg/logging/googlepubsub/list.go deleted file mode 100644 index 17a9c1f2e..000000000 --- a/pkg/logging/googlepubsub/list.go +++ /dev/null @@ -1,77 +0,0 @@ -package googlepubsub - -import ( - "fmt" - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// ListCommand calls the Fastly API to list Google Cloud Pub/Sub logging endpoints. -type ListCommand struct { - common.Base - manifest manifest.Data - Input fastly.ListPubsubsInput -} - -// NewListCommand returns a usable command registered under the parent. -func NewListCommand(parent common.Registerer, globals *config.Data) *ListCommand { - var c ListCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("list", "List Google Cloud Pub/Sub endpoints on a Fastly service version") - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Input.ServiceVersion) - return &c -} - -// Exec invokes the application logic for the command. -func (c *ListCommand) Exec(in io.Reader, out io.Writer) error { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - c.Input.ServiceID = serviceID - - googlepubsubs, err := c.Globals.Client.ListPubsubs(&c.Input) - if err != nil { - return err - } - - if !c.Globals.Verbose() { - tw := text.NewTable(out) - tw.AddHeader("SERVICE", "VERSION", "NAME") - for _, googlepubsub := range googlepubsubs { - tw.AddLine(googlepubsub.ServiceID, googlepubsub.ServiceVersion, googlepubsub.Name) - } - tw.Print() - return nil - } - - fmt.Fprintf(out, "Service ID: %s\n", c.Input.ServiceID) - fmt.Fprintf(out, "Version: %d\n", c.Input.ServiceVersion) - for i, googlepubsub := range googlepubsubs { - fmt.Fprintf(out, "\tGoogle Cloud Pub/Sub %d/%d\n", i+1, len(googlepubsubs)) - fmt.Fprintf(out, "\t\tService ID: %s\n", googlepubsub.ServiceID) - fmt.Fprintf(out, "\t\tVersion: %d\n", googlepubsub.ServiceVersion) - fmt.Fprintf(out, "\t\tName: %s\n", googlepubsub.Name) - fmt.Fprintf(out, "\t\tUser: %s\n", googlepubsub.User) - fmt.Fprintf(out, "\t\tSecret key: %s\n", googlepubsub.SecretKey) - fmt.Fprintf(out, "\t\tProject ID: %s\n", googlepubsub.ProjectID) - fmt.Fprintf(out, "\t\tTopic: %s\n", googlepubsub.Topic) - fmt.Fprintf(out, "\t\tFormat: %s\n", googlepubsub.Format) - fmt.Fprintf(out, "\t\tFormat version: %d\n", googlepubsub.FormatVersion) - fmt.Fprintf(out, "\t\tResponse condition: %s\n", googlepubsub.ResponseCondition) - fmt.Fprintf(out, "\t\tPlacement: %s\n", googlepubsub.Placement) - - } - fmt.Fprintln(out) - - return nil -} diff --git a/pkg/logging/googlepubsub/root.go b/pkg/logging/googlepubsub/root.go deleted file mode 100644 index 45a40986e..000000000 --- a/pkg/logging/googlepubsub/root.go +++ /dev/null @@ -1,28 +0,0 @@ -package googlepubsub - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/config" -) - -// RootCommand is the parent command for all subcommands in this package. -// It should be installed under the primary root command. -type RootCommand struct { - common.Base - // no flags -} - -// NewRootCommand returns a new command registered in the parent. -func NewRootCommand(parent common.Registerer, globals *config.Data) *RootCommand { - var c RootCommand - c.Globals = globals - c.CmdClause = parent.Command("googlepubsub", "Manipulate Fastly service version Google Cloud Pub/Sub logging endpoints") - return &c -} - -// Exec implements the command interface. -func (c *RootCommand) Exec(in io.Reader, out io.Writer) error { - panic("unreachable") -} diff --git a/pkg/logging/googlepubsub/update.go b/pkg/logging/googlepubsub/update.go deleted file mode 100644 index 88f48b52b..000000000 --- a/pkg/logging/googlepubsub/update.go +++ /dev/null @@ -1,127 +0,0 @@ -package googlepubsub - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// UpdateCommand calls the Fastly API to update a Google Cloud Pub/Sub logging endpoint. -type UpdateCommand struct { - common.Base - manifest manifest.Data - - // required - EndpointName string // Can't shadow common.Base method Name(). - Version int - - // optional - NewName common.OptionalString - User common.OptionalString - SecretKey common.OptionalString - ProjectID common.OptionalString - Topic common.OptionalString - Format common.OptionalString - FormatVersion common.OptionalUint - Placement common.OptionalString - ResponseCondition common.OptionalString -} - -// NewUpdateCommand returns a usable command registered under the parent. -func NewUpdateCommand(parent common.Registerer, globals *config.Data) *UpdateCommand { - var c UpdateCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - - c.CmdClause = parent.Command("update", "Update a Google Cloud Pub/Sub logging endpoint on a Fastly service version") - - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Version) - c.CmdClause.Flag("name", "The name of the Google Cloud Pub/Sub logging object").Short('n').Required().StringVar(&c.EndpointName) - - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("new-name", "New name of the Google Cloud Pub/Sub logging object").Action(c.NewName.Set).StringVar(&c.NewName.Value) - c.CmdClause.Flag("user", "Your Google Cloud Platform service account email address. The client_email field in your service account authentication JSON").Action(c.User.Set).StringVar(&c.User.Value) - c.CmdClause.Flag("secret-key", "Your Google Cloud Platform account secret key. The private_key field in your service account authentication JSON").Action(c.SecretKey.Set).StringVar(&c.SecretKey.Value) - c.CmdClause.Flag("topic", "The Google Cloud Pub/Sub topic to which logs will be published").Action(c.Topic.Set).StringVar(&c.Topic.Value) - c.CmdClause.Flag("project-id", "The ID of your Google Cloud Platform project").Action(c.ProjectID.Set).StringVar(&c.ProjectID.Value) - c.CmdClause.Flag("format", "Apache style log formatting").Action(c.Format.Set).StringVar(&c.Format.Value) - c.CmdClause.Flag("format-version", "The version of the custom logging format used for the configured endpoint. Can be either 2 (default) or 1").Action(c.FormatVersion.Set).UintVar(&c.FormatVersion.Value) - c.CmdClause.Flag("placement", "Where in the generated VCL the logging call should be placed, overriding any format_version default. Can be none or waf_debug. This field is not required and has no default value").Action(c.Placement.Set).StringVar(&c.Placement.Value) - c.CmdClause.Flag("response-condition", "The name of an existing condition in the configured endpoint, or leave blank to always execute").Action(c.ResponseCondition.Set).StringVar(&c.ResponseCondition.Value) - - return &c -} - -// createInput transforms values parsed from CLI flags into an object to be used by the API client library. -func (c *UpdateCommand) createInput() (*fastly.UpdatePubsubInput, error) { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return nil, errors.ErrNoServiceID - } - - input := fastly.UpdatePubsubInput{ - ServiceID: serviceID, - ServiceVersion: c.Version, - Name: c.EndpointName, - } - - if c.NewName.WasSet { - input.NewName = fastly.String(c.NewName.Value) - } - - if c.User.WasSet { - input.User = fastly.String(c.User.Value) - } - - if c.SecretKey.WasSet { - input.SecretKey = fastly.String(c.SecretKey.Value) - } - - if c.Topic.WasSet { - input.Topic = fastly.String(c.Topic.Value) - } - - if c.ProjectID.WasSet { - input.ProjectID = fastly.String(c.ProjectID.Value) - } - - if c.Format.WasSet { - input.Format = fastly.String(c.Format.Value) - } - - if c.FormatVersion.WasSet { - input.FormatVersion = fastly.Uint(c.FormatVersion.Value) - } - - if c.ResponseCondition.WasSet { - input.ResponseCondition = fastly.String(c.ResponseCondition.Value) - } - - if c.Placement.WasSet { - input.Placement = fastly.String(c.Placement.Value) - } - - return &input, nil -} - -// Exec invokes the application logic for the command. -func (c *UpdateCommand) Exec(in io.Reader, out io.Writer) error { - input, err := c.createInput() - if err != nil { - return err - } - - googlepubsub, err := c.Globals.Client.UpdatePubsub(input) - if err != nil { - return err - } - - text.Success(out, "Updated Google Cloud Pub/Sub logging endpoint %s (service %s version %d)", googlepubsub.Name, googlepubsub.ServiceID, googlepubsub.ServiceVersion) - return nil -} diff --git a/pkg/logging/heroku/create.go b/pkg/logging/heroku/create.go deleted file mode 100644 index 57330ff18..000000000 --- a/pkg/logging/heroku/create.go +++ /dev/null @@ -1,104 +0,0 @@ -package heroku - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// CreateCommand calls the Fastly API to create a Heroku logging endpoint. -type CreateCommand struct { - common.Base - manifest manifest.Data - - // required - EndpointName string // Can't shadow common.Base method Name(). - Version int - Token string - URL string - - // optional - Format common.OptionalString - FormatVersion common.OptionalUint - ResponseCondition common.OptionalString - Placement common.OptionalString -} - -// NewCreateCommand returns a usable command registered under the parent. -func NewCreateCommand(parent common.Registerer, globals *config.Data) *CreateCommand { - var c CreateCommand - - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("create", "Create a Heroku logging endpoint on a Fastly service version").Alias("add") - - c.CmdClause.Flag("name", "The name of the Heroku logging object. Used as a primary key for API access").Short('n').Required().StringVar(&c.EndpointName) - - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Version) - c.CmdClause.Flag("url", "The url to stream logs to").Required().StringVar(&c.URL) - c.CmdClause.Flag("auth-token", "The token to use for authentication (https://devcenter.heroku.com/articles/add-on-partner-log-integration)").Required().StringVar(&c.Token) - - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("format", "Apache style log formatting").Action(c.Format.Set).StringVar(&c.Format.Value) - c.CmdClause.Flag("format-version", "The version of the custom logging format used for the configured endpoint. Can be either 2 (default) or 1").Action(c.FormatVersion.Set).UintVar(&c.FormatVersion.Value) - c.CmdClause.Flag("response-condition", "The name of an existing condition in the configured endpoint, or leave blank to always execute").Action(c.ResponseCondition.Set).StringVar(&c.ResponseCondition.Value) - c.CmdClause.Flag("placement", "Where in the generated VCL the logging call should be placed, overriding any format_version default. Can be none or waf_debug").Action(c.Placement.Set).StringVar(&c.Placement.Value) - - return &c -} - -// createInput transforms values parsed from CLI flags into an object to be used by the API client library. -func (c *CreateCommand) createInput() (*fastly.CreateHerokuInput, error) { - var input fastly.CreateHerokuInput - - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return nil, errors.ErrNoServiceID - } - - input.ServiceID = serviceID - input.ServiceVersion = c.Version - input.Name = c.EndpointName - input.Token = c.Token - input.URL = c.URL - - if c.Format.WasSet { - input.Format = c.Format.Value - } - - if c.FormatVersion.WasSet { - input.FormatVersion = c.FormatVersion.Value - } - - if c.ResponseCondition.WasSet { - input.ResponseCondition = c.ResponseCondition.Value - } - - if c.Placement.WasSet { - input.Placement = c.Placement.Value - } - - return &input, nil -} - -// Exec invokes the application logic for the command. -func (c *CreateCommand) Exec(in io.Reader, out io.Writer) error { - input, err := c.createInput() - if err != nil { - return err - } - - d, err := c.Globals.Client.CreateHeroku(input) - if err != nil { - return err - } - - text.Success(out, "Created Heroku logging endpoint %s (service %s version %d)", d.Name, d.ServiceID, d.ServiceVersion) - return nil -} diff --git a/pkg/logging/heroku/delete.go b/pkg/logging/heroku/delete.go deleted file mode 100644 index c4e9d5178..000000000 --- a/pkg/logging/heroku/delete.go +++ /dev/null @@ -1,51 +0,0 @@ -package heroku - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// DeleteCommand calls the Fastly API to delete a Heroku logging endpoint. -type DeleteCommand struct { - common.Base - manifest manifest.Data - Input fastly.DeleteHerokuInput -} - -// NewDeleteCommand returns a usable command registered under the parent. -func NewDeleteCommand(parent common.Registerer, globals *config.Data) *DeleteCommand { - var c DeleteCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("delete", "Delete a Heroku logging endpoint on a Fastly service version").Alias("remove") - - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Input.ServiceVersion) - c.CmdClause.Flag("name", "The name of the Heroku logging object").Short('n').Required().StringVar(&c.Input.Name) - - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - - return &c -} - -// Exec invokes the application logic for the command. -func (c *DeleteCommand) Exec(in io.Reader, out io.Writer) error { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - c.Input.ServiceID = serviceID - - if err := c.Globals.Client.DeleteHeroku(&c.Input); err != nil { - return err - } - - text.Success(out, "Deleted Heroku logging endpoint %s (service %s version %d)", c.Input.Name, c.Input.ServiceID, c.Input.ServiceVersion) - return nil -} diff --git a/pkg/logging/heroku/describe.go b/pkg/logging/heroku/describe.go deleted file mode 100644 index 76704de85..000000000 --- a/pkg/logging/heroku/describe.go +++ /dev/null @@ -1,58 +0,0 @@ -package heroku - -import ( - "fmt" - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/go-fastly/v3/fastly" -) - -// DescribeCommand calls the Fastly API to describe a Heroku logging endpoint. -type DescribeCommand struct { - common.Base - manifest manifest.Data - Input fastly.GetHerokuInput -} - -// NewDescribeCommand returns a usable command registered under the parent. -func NewDescribeCommand(parent common.Registerer, globals *config.Data) *DescribeCommand { - var c DescribeCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("describe", "Show detailed information about a Heroku logging endpoint on a Fastly service version").Alias("get") - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Input.ServiceVersion) - c.CmdClause.Flag("name", "The name of the Heroku logging object").Short('n').Required().StringVar(&c.Input.Name) - return &c -} - -// Exec invokes the application logic for the command. -func (c *DescribeCommand) Exec(in io.Reader, out io.Writer) error { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - c.Input.ServiceID = serviceID - - heroku, err := c.Globals.Client.GetHeroku(&c.Input) - if err != nil { - return err - } - - fmt.Fprintf(out, "Service ID: %s\n", heroku.ServiceID) - fmt.Fprintf(out, "Version: %d\n", heroku.ServiceVersion) - fmt.Fprintf(out, "Name: %s\n", heroku.Name) - fmt.Fprintf(out, "URL: %s\n", heroku.URL) - fmt.Fprintf(out, "Token: %s\n", heroku.Token) - fmt.Fprintf(out, "Format: %s\n", heroku.Format) - fmt.Fprintf(out, "Format version: %d\n", heroku.FormatVersion) - fmt.Fprintf(out, "Response condition: %s\n", heroku.ResponseCondition) - fmt.Fprintf(out, "Placement: %s\n", heroku.Placement) - - return nil -} diff --git a/pkg/logging/heroku/heroku_integration_test.go b/pkg/logging/heroku/heroku_integration_test.go deleted file mode 100644 index b9916c914..000000000 --- a/pkg/logging/heroku/heroku_integration_test.go +++ /dev/null @@ -1,381 +0,0 @@ -package heroku_test - -import ( - "bytes" - "errors" - "io" - "net/http" - "strings" - "testing" - - "github.com/fastly/cli/pkg/app" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/mock" - "github.com/fastly/cli/pkg/testutil" - "github.com/fastly/cli/pkg/update" - "github.com/fastly/go-fastly/v3/fastly" -) - -func TestHerokuCreate(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "heroku", "create", "--service-id", "123", "--version", "1", "--name", "log", "--url", "example.com"}, - wantError: "error parsing arguments: required flag --auth-token not provided", - }, - { - args: []string{"logging", "heroku", "create", "--service-id", "123", "--version", "1", "--name", "log", "--auth-token", "abc"}, - wantError: "error parsing arguments: required flag --url not provided", - }, - { - args: []string{"logging", "heroku", "create", "--service-id", "123", "--version", "1", "--name", "log", "--auth-token", "abc", "--url", "example.com"}, - api: mock.API{CreateHerokuFn: createHerokuOK}, - wantOutput: "Created Heroku logging endpoint log (service 123 version 1)", - }, - { - args: []string{"logging", "heroku", "create", "--service-id", "123", "--version", "1", "--name", "log", "--auth-token", "abc", "--url", "example.com"}, - api: mock.API{CreateHerokuFn: createHerokuError}, - wantError: errTest.Error(), - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertStringContains(t, out.String(), testcase.wantOutput) - }) - } -} - -func TestHerokuList(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "heroku", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListHerokusFn: listHerokusOK}, - wantOutput: listHerokusShortOutput, - }, - { - args: []string{"logging", "heroku", "list", "--service-id", "123", "--version", "1", "--verbose"}, - api: mock.API{ListHerokusFn: listHerokusOK}, - wantOutput: listHerokusVerboseOutput, - }, - { - args: []string{"logging", "heroku", "list", "--service-id", "123", "--version", "1", "-v"}, - api: mock.API{ListHerokusFn: listHerokusOK}, - wantOutput: listHerokusVerboseOutput, - }, - { - args: []string{"logging", "heroku", "--verbose", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListHerokusFn: listHerokusOK}, - wantOutput: listHerokusVerboseOutput, - }, - { - args: []string{"logging", "-v", "heroku", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListHerokusFn: listHerokusOK}, - wantOutput: listHerokusVerboseOutput, - }, - { - args: []string{"logging", "heroku", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListHerokusFn: listHerokusError}, - wantError: errTest.Error(), - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertString(t, testcase.wantOutput, out.String()) - }) - } -} - -func TestHerokuDescribe(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "heroku", "describe", "--service-id", "123", "--version", "1"}, - wantError: "error parsing arguments: required flag --name not provided", - }, - { - args: []string{"logging", "heroku", "describe", "--service-id", "123", "--version", "1", "--name", "logs"}, - api: mock.API{GetHerokuFn: getHerokuError}, - wantError: errTest.Error(), - }, - { - args: []string{"logging", "heroku", "describe", "--service-id", "123", "--version", "1", "--name", "logs"}, - api: mock.API{GetHerokuFn: getHerokuOK}, - wantOutput: describeHerokuOutput, - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertString(t, testcase.wantOutput, out.String()) - }) - } -} - -func TestHerokuUpdate(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "heroku", "update", "--service-id", "123", "--version", "1", "--new-name", "log"}, - wantError: "error parsing arguments: required flag --name not provided", - }, - { - args: []string{"logging", "heroku", "update", "--service-id", "123", "--version", "1", "--name", "logs", "--new-name", "log"}, - api: mock.API{UpdateHerokuFn: updateHerokuError}, - wantError: errTest.Error(), - }, - { - args: []string{"logging", "heroku", "update", "--service-id", "123", "--version", "1", "--name", "logs", "--new-name", "log"}, - api: mock.API{UpdateHerokuFn: updateHerokuOK}, - wantOutput: "Updated Heroku logging endpoint log (service 123 version 1)", - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertStringContains(t, out.String(), testcase.wantOutput) - }) - } -} - -func TestHerokuDelete(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "heroku", "delete", "--service-id", "123", "--version", "1"}, - wantError: "error parsing arguments: required flag --name not provided", - }, - { - args: []string{"logging", "heroku", "delete", "--service-id", "123", "--version", "1", "--name", "logs"}, - api: mock.API{DeleteHerokuFn: deleteHerokuError}, - wantError: errTest.Error(), - }, - { - args: []string{"logging", "heroku", "delete", "--service-id", "123", "--version", "1", "--name", "logs"}, - api: mock.API{DeleteHerokuFn: deleteHerokuOK}, - wantOutput: "Deleted Heroku logging endpoint logs (service 123 version 1)", - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertStringContains(t, out.String(), testcase.wantOutput) - }) - } -} - -var errTest = errors.New("fixture error") - -func createHerokuOK(i *fastly.CreateHerokuInput) (*fastly.Heroku, error) { - s := fastly.Heroku{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - } - - if i.Name != "" { - s.Name = i.Name - } - - return &s, nil -} - -func createHerokuError(i *fastly.CreateHerokuInput) (*fastly.Heroku, error) { - return nil, errTest -} - -func listHerokusOK(i *fastly.ListHerokusInput) ([]*fastly.Heroku, error) { - return []*fastly.Heroku{ - { - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "logs", - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - URL: "example.com", - Token: "abc", - ResponseCondition: "Prevent default logging", - Placement: "none", - }, - { - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "analytics", - URL: "bar.com", - Token: "abc", - Format: `%h %l %u %t "%r" %>s %b`, - ResponseCondition: "Prevent default logging", - FormatVersion: 2, - Placement: "none", - }, - }, nil -} - -func listHerokusError(i *fastly.ListHerokusInput) ([]*fastly.Heroku, error) { - return nil, errTest -} - -var listHerokusShortOutput = strings.TrimSpace(` -SERVICE VERSION NAME -123 1 logs -123 1 analytics -`) + "\n" - -var listHerokusVerboseOutput = strings.TrimSpace(` -Fastly API token not provided -Fastly API endpoint: https://api.fastly.com -Service ID: 123 -Version: 1 - Heroku 1/2 - Service ID: 123 - Version: 1 - Name: logs - URL: example.com - Token: abc - Format: %h %l %u %t "%r" %>s %b - Format version: 2 - Response condition: Prevent default logging - Placement: none - Heroku 2/2 - Service ID: 123 - Version: 1 - Name: analytics - URL: bar.com - Token: abc - Format: %h %l %u %t "%r" %>s %b - Format version: 2 - Response condition: Prevent default logging - Placement: none -`) + "\n\n" - -func getHerokuOK(i *fastly.GetHerokuInput) (*fastly.Heroku, error) { - return &fastly.Heroku{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "logs", - URL: "example.com", - Token: "abc", - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - ResponseCondition: "Prevent default logging", - Placement: "none", - }, nil -} - -func getHerokuError(i *fastly.GetHerokuInput) (*fastly.Heroku, error) { - return nil, errTest -} - -var describeHerokuOutput = strings.TrimSpace(` -Service ID: 123 -Version: 1 -Name: logs -URL: example.com -Token: abc -Format: %h %l %u %t "%r" %>s %b -Format version: 2 -Response condition: Prevent default logging -Placement: none -`) + "\n" - -func updateHerokuOK(i *fastly.UpdateHerokuInput) (*fastly.Heroku, error) { - return &fastly.Heroku{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "log", - URL: "example.com", - Token: "abc", - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - ResponseCondition: "Prevent default logging", - Placement: "none", - }, nil -} - -func updateHerokuError(i *fastly.UpdateHerokuInput) (*fastly.Heroku, error) { - return nil, errTest -} - -func deleteHerokuOK(i *fastly.DeleteHerokuInput) error { - return nil -} - -func deleteHerokuError(i *fastly.DeleteHerokuInput) error { - return errTest -} diff --git a/pkg/logging/heroku/heroku_test.go b/pkg/logging/heroku/heroku_test.go deleted file mode 100644 index a7c64eb91..000000000 --- a/pkg/logging/heroku/heroku_test.go +++ /dev/null @@ -1,188 +0,0 @@ -package heroku - -import ( - "testing" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/mock" - "github.com/fastly/cli/pkg/testutil" - "github.com/fastly/go-fastly/v3/fastly" -) - -func TestCreateHerokuInput(t *testing.T) { - for _, testcase := range []struct { - name string - cmd *CreateCommand - want *fastly.CreateHerokuInput - wantError string - }{ - { - name: "required values set flag serviceID", - cmd: createCommandRequired(), - want: &fastly.CreateHerokuInput{ - ServiceID: "123", - ServiceVersion: 2, - Name: "log", - Token: "tkn", - URL: "example.com", - }, - }, - { - name: "all values set flag serviceID", - cmd: createCommandAll(), - want: &fastly.CreateHerokuInput{ - ServiceID: "123", - ServiceVersion: 2, - Name: "log", - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - Token: "tkn", - URL: "example.com", - ResponseCondition: "Prevent default logging", - Placement: "none", - }, - }, - { - name: "error missing serviceID", - cmd: createCommandMissingServiceID(), - want: nil, - wantError: errors.ErrNoServiceID.Error(), - }, - } { - t.Run(testcase.name, func(t *testing.T) { - have, err := testcase.cmd.createInput() - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertEqual(t, testcase.want, have) - }) - } -} - -func TestUpdateHerokuInput(t *testing.T) { - for _, testcase := range []struct { - name string - cmd *UpdateCommand - api mock.API - want *fastly.UpdateHerokuInput - wantError string - }{ - { - name: "no updates", - cmd: updateCommandNoUpdates(), - api: mock.API{GetHerokuFn: getHerokuOK}, - want: &fastly.UpdateHerokuInput{ - ServiceID: "123", - ServiceVersion: 2, - Name: "log", - }, - }, - { - name: "all values set flag serviceID", - cmd: updateCommandAll(), - api: mock.API{GetHerokuFn: getHerokuOK}, - want: &fastly.UpdateHerokuInput{ - ServiceID: "123", - ServiceVersion: 2, - Name: "log", - NewName: fastly.String("new1"), - Format: fastly.String("new2"), - FormatVersion: fastly.Uint(3), - Token: fastly.String("new3"), - URL: fastly.String("new4"), - ResponseCondition: fastly.String("new5"), - Placement: fastly.String("new6"), - }, - }, - { - name: "error missing serviceID", - cmd: updateCommandMissingServiceID(), - want: nil, - wantError: errors.ErrNoServiceID.Error(), - }, - } { - t.Run(testcase.name, func(t *testing.T) { - testcase.cmd.Base.Globals.Client = testcase.api - - have, err := testcase.cmd.createInput() - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertEqual(t, testcase.want, have) - }) - } -} - -func createCommandRequired() *CreateCommand { - return &CreateCommand{ - manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, - EndpointName: "log", - Token: "tkn", - URL: "example.com", - Version: 2, - } -} - -func createCommandAll() *CreateCommand { - return &CreateCommand{ - manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, - EndpointName: "log", - Token: "tkn", - URL: "example.com", - Version: 2, - Format: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: `%h %l %u %t "%r" %>s %b`}, - FormatVersion: common.OptionalUint{Optional: common.Optional{WasSet: true}, Value: 2}, - ResponseCondition: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "Prevent default logging"}, - Placement: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "none"}, - } -} - -func createCommandMissingServiceID() *CreateCommand { - res := createCommandAll() - res.manifest = manifest.Data{} - return res -} - -func updateCommandNoUpdates() *UpdateCommand { - return &UpdateCommand{ - Base: common.Base{Globals: &config.Data{Client: nil}}, - manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, - EndpointName: "log", - Version: 2, - } -} - -func updateCommandAll() *UpdateCommand { - return &UpdateCommand{ - Base: common.Base{Globals: &config.Data{Client: nil}}, - manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, - EndpointName: "log", - Version: 2, - NewName: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new1"}, - Format: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new2"}, - FormatVersion: common.OptionalUint{Optional: common.Optional{WasSet: true}, Value: 3}, - Token: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new3"}, - URL: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new4"}, - ResponseCondition: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new5"}, - Placement: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new6"}, - } -} - -func updateCommandMissingServiceID() *UpdateCommand { - res := updateCommandAll() - res.manifest = manifest.Data{} - return res -} - -func getHerokuOK(i *fastly.GetHerokuInput) (*fastly.Heroku, error) { - return &fastly.Heroku{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "logs", - Token: "tkn", - URL: "example.com", - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - ResponseCondition: "Prevent default logging", - Placement: "none", - }, nil -} diff --git a/pkg/logging/heroku/list.go b/pkg/logging/heroku/list.go deleted file mode 100644 index 254eabf4c..000000000 --- a/pkg/logging/heroku/list.go +++ /dev/null @@ -1,74 +0,0 @@ -package heroku - -import ( - "fmt" - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// ListCommand calls the Fastly API to list Heroku logging endpoints. -type ListCommand struct { - common.Base - manifest manifest.Data - Input fastly.ListHerokusInput -} - -// NewListCommand returns a usable command registered under the parent. -func NewListCommand(parent common.Registerer, globals *config.Data) *ListCommand { - var c ListCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("list", "List Heroku endpoints on a Fastly service version") - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Input.ServiceVersion) - return &c -} - -// Exec invokes the application logic for the command. -func (c *ListCommand) Exec(in io.Reader, out io.Writer) error { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - c.Input.ServiceID = serviceID - - herokus, err := c.Globals.Client.ListHerokus(&c.Input) - if err != nil { - return err - } - - if !c.Globals.Verbose() { - tw := text.NewTable(out) - tw.AddHeader("SERVICE", "VERSION", "NAME") - for _, heroku := range herokus { - tw.AddLine(heroku.ServiceID, heroku.ServiceVersion, heroku.Name) - } - tw.Print() - return nil - } - - fmt.Fprintf(out, "Service ID: %s\n", c.Input.ServiceID) - fmt.Fprintf(out, "Version: %d\n", c.Input.ServiceVersion) - for i, heroku := range herokus { - fmt.Fprintf(out, "\tHeroku %d/%d\n", i+1, len(herokus)) - fmt.Fprintf(out, "\t\tService ID: %s\n", heroku.ServiceID) - fmt.Fprintf(out, "\t\tVersion: %d\n", heroku.ServiceVersion) - fmt.Fprintf(out, "\t\tName: %s\n", heroku.Name) - fmt.Fprintf(out, "\t\tURL: %s\n", heroku.URL) - fmt.Fprintf(out, "\t\tToken: %s\n", heroku.Token) - fmt.Fprintf(out, "\t\tFormat: %s\n", heroku.Format) - fmt.Fprintf(out, "\t\tFormat version: %d\n", heroku.FormatVersion) - fmt.Fprintf(out, "\t\tResponse condition: %s\n", heroku.ResponseCondition) - fmt.Fprintf(out, "\t\tPlacement: %s\n", heroku.Placement) - } - fmt.Fprintln(out) - - return nil -} diff --git a/pkg/logging/heroku/root.go b/pkg/logging/heroku/root.go deleted file mode 100644 index bac473343..000000000 --- a/pkg/logging/heroku/root.go +++ /dev/null @@ -1,28 +0,0 @@ -package heroku - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/config" -) - -// RootCommand is the parent command for all subcommands in this package. -// It should be installed under the primary root command. -type RootCommand struct { - common.Base - // no flags -} - -// NewRootCommand returns a new command registered in the parent. -func NewRootCommand(parent common.Registerer, globals *config.Data) *RootCommand { - var c RootCommand - c.Globals = globals - c.CmdClause = parent.Command("heroku", "Manipulate Fastly service version Heroku logging endpoints") - return &c -} - -// Exec implements the command interface. -func (c *RootCommand) Exec(in io.Reader, out io.Writer) error { - panic("unreachable") -} diff --git a/pkg/logging/heroku/update.go b/pkg/logging/heroku/update.go deleted file mode 100644 index 326f243a6..000000000 --- a/pkg/logging/heroku/update.go +++ /dev/null @@ -1,115 +0,0 @@ -package heroku - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// UpdateCommand calls the Fastly API to update a Heroku logging endpoint. -type UpdateCommand struct { - common.Base - manifest manifest.Data - - // required - EndpointName string // Can't shadow common.Base method Name(). - Version int - - // optional - NewName common.OptionalString - Format common.OptionalString - FormatVersion common.OptionalUint - URL common.OptionalString - Token common.OptionalString - ResponseCondition common.OptionalString - Placement common.OptionalString -} - -// NewUpdateCommand returns a usable command registered under the parent. -func NewUpdateCommand(parent common.Registerer, globals *config.Data) *UpdateCommand { - var c UpdateCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - - c.CmdClause = parent.Command("update", "Update a Heroku logging endpoint on a Fastly service version") - - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Version) - c.CmdClause.Flag("name", "The name of the Heroku logging object").Short('n').Required().StringVar(&c.EndpointName) - - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("new-name", "New name of the Heroku logging object").Action(c.NewName.Set).StringVar(&c.NewName.Value) - c.CmdClause.Flag("format", "Apache style log formatting").Action(c.Format.Set).StringVar(&c.Format.Value) - c.CmdClause.Flag("format-version", "The version of the custom logging format used for the configured endpoint. Can be either 2 (default) or 1").Action(c.FormatVersion.Set).UintVar(&c.FormatVersion.Value) - c.CmdClause.Flag("url", "The url to stream logs to").Action(c.URL.Set).StringVar(&c.URL.Value) - c.CmdClause.Flag("auth-token", "The token to use for authentication (https://devcenter.heroku.com/articles/add-on-partner-log-integration)").Action(c.Token.Set).StringVar(&c.Token.Value) - c.CmdClause.Flag("response-condition", "The name of an existing condition in the configured endpoint, or leave blank to always execute").Action(c.ResponseCondition.Set).StringVar(&c.ResponseCondition.Value) - c.CmdClause.Flag("placement", "Where in the generated VCL the logging call should be placed, overriding any format_version default. Can be none or waf_debug").Action(c.Placement.Set).StringVar(&c.Placement.Value) - - return &c -} - -// createInput transforms values parsed from CLI flags into an object to be used by the API client library. -func (c *UpdateCommand) createInput() (*fastly.UpdateHerokuInput, error) { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return nil, errors.ErrNoServiceID - } - - input := fastly.UpdateHerokuInput{ - ServiceID: serviceID, - ServiceVersion: c.Version, - Name: c.EndpointName, - } - - if c.NewName.WasSet { - input.NewName = fastly.String(c.NewName.Value) - } - - if c.Format.WasSet { - input.Format = fastly.String(c.Format.Value) - } - - if c.FormatVersion.WasSet { - input.FormatVersion = fastly.Uint(c.FormatVersion.Value) - } - - if c.Token.WasSet { - input.Token = fastly.String(c.Token.Value) - } - - if c.URL.WasSet { - input.URL = fastly.String(c.URL.Value) - } - - if c.ResponseCondition.WasSet { - input.ResponseCondition = fastly.String(c.ResponseCondition.Value) - } - - if c.Placement.WasSet { - input.Placement = fastly.String(c.Placement.Value) - } - - return &input, nil -} - -// Exec invokes the application logic for the command. -func (c *UpdateCommand) Exec(in io.Reader, out io.Writer) error { - input, err := c.createInput() - if err != nil { - return err - } - - heroku, err := c.Globals.Client.UpdateHeroku(input) - if err != nil { - return err - } - - text.Success(out, "Updated Heroku logging endpoint %s (service %s version %d)", heroku.Name, heroku.ServiceID, heroku.ServiceVersion) - return nil -} diff --git a/pkg/logging/honeycomb/create.go b/pkg/logging/honeycomb/create.go deleted file mode 100644 index a4bca2b57..000000000 --- a/pkg/logging/honeycomb/create.go +++ /dev/null @@ -1,103 +0,0 @@ -package honeycomb - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// CreateCommand calls the Fastly API to create a Honeycomb logging endpoint. -type CreateCommand struct { - common.Base - manifest manifest.Data - - // required - EndpointName string // Can't shadow common.Base method Name(). - Version int - Token string - Dataset string - - // optional - Format common.OptionalString - FormatVersion common.OptionalUint - ResponseCondition common.OptionalString - Placement common.OptionalString -} - -// NewCreateCommand returns a usable command registered under the parent. -func NewCreateCommand(parent common.Registerer, globals *config.Data) *CreateCommand { - var c CreateCommand - - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("create", "Create a Honeycomb logging endpoint on a Fastly service version").Alias("add") - - c.CmdClause.Flag("name", "The name of the Honeycomb logging object. Used as a primary key for API access").Short('n').Required().StringVar(&c.EndpointName) - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Version) - c.CmdClause.Flag("dataset", "The Honeycomb Dataset you want to log to").Required().StringVar(&c.Dataset) - c.CmdClause.Flag("auth-token", "The Write Key from the Account page of your Honeycomb account").Required().StringVar(&c.Token) - - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("format", "Apache style log formatting. Your log must produce valid JSON that Honeycomb can ingest").Action(c.Format.Set).StringVar(&c.Format.Value) - c.CmdClause.Flag("format-version", "The version of the custom logging format used for the configured endpoint. Can be either 2 (default) or 1").Action(c.FormatVersion.Set).UintVar(&c.FormatVersion.Value) - c.CmdClause.Flag("response-condition", "The name of an existing condition in the configured endpoint, or leave blank to always execute").Action(c.ResponseCondition.Set).StringVar(&c.ResponseCondition.Value) - c.CmdClause.Flag("placement", "Where in the generated VCL the logging call should be placed, overriding any format_version default. Can be none or waf_debug").Action(c.Placement.Set).StringVar(&c.Placement.Value) - - return &c -} - -// createInput transforms values parsed from CLI flags into an object to be used by the API client library. -func (c *CreateCommand) createInput() (*fastly.CreateHoneycombInput, error) { - var input fastly.CreateHoneycombInput - - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return nil, errors.ErrNoServiceID - } - - input.ServiceID = serviceID - input.ServiceVersion = c.Version - input.Name = c.EndpointName - input.Token = c.Token - input.Dataset = c.Dataset - - if c.Format.WasSet { - input.Format = c.Format.Value - } - - if c.FormatVersion.WasSet { - input.FormatVersion = c.FormatVersion.Value - } - - if c.ResponseCondition.WasSet { - input.ResponseCondition = c.ResponseCondition.Value - } - - if c.Placement.WasSet { - input.Placement = c.Placement.Value - } - - return &input, nil -} - -// Exec invokes the application logic for the command. -func (c *CreateCommand) Exec(in io.Reader, out io.Writer) error { - input, err := c.createInput() - if err != nil { - return err - } - - d, err := c.Globals.Client.CreateHoneycomb(input) - if err != nil { - return err - } - - text.Success(out, "Created Honeycomb logging endpoint %s (service %s version %d)", d.Name, d.ServiceID, d.ServiceVersion) - return nil -} diff --git a/pkg/logging/honeycomb/delete.go b/pkg/logging/honeycomb/delete.go deleted file mode 100644 index dc8638e0e..000000000 --- a/pkg/logging/honeycomb/delete.go +++ /dev/null @@ -1,51 +0,0 @@ -package honeycomb - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// DeleteCommand calls the Fastly API to delete a Honeycomb logging endpoint. -type DeleteCommand struct { - common.Base - manifest manifest.Data - Input fastly.DeleteHoneycombInput -} - -// NewDeleteCommand returns a usable command registered under the parent. -func NewDeleteCommand(parent common.Registerer, globals *config.Data) *DeleteCommand { - var c DeleteCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("delete", "Delete a Honeycomb logging endpoint on a Fastly service version").Alias("remove") - - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Input.ServiceVersion) - c.CmdClause.Flag("name", "The name of the Honeycomb logging object").Short('n').Required().StringVar(&c.Input.Name) - - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - - return &c -} - -// Exec invokes the application logic for the command. -func (c *DeleteCommand) Exec(in io.Reader, out io.Writer) error { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - c.Input.ServiceID = serviceID - - if err := c.Globals.Client.DeleteHoneycomb(&c.Input); err != nil { - return err - } - - text.Success(out, "Deleted Honeycomb logging endpoint %s (service %s version %d)", c.Input.Name, c.Input.ServiceID, c.Input.ServiceVersion) - return nil -} diff --git a/pkg/logging/honeycomb/describe.go b/pkg/logging/honeycomb/describe.go deleted file mode 100644 index dae017410..000000000 --- a/pkg/logging/honeycomb/describe.go +++ /dev/null @@ -1,58 +0,0 @@ -package honeycomb - -import ( - "fmt" - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/go-fastly/v3/fastly" -) - -// DescribeCommand calls the Fastly API to describe a Honeycomb logging endpoint. -type DescribeCommand struct { - common.Base - manifest manifest.Data - Input fastly.GetHoneycombInput -} - -// NewDescribeCommand returns a usable command registered under the parent. -func NewDescribeCommand(parent common.Registerer, globals *config.Data) *DescribeCommand { - var c DescribeCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("describe", "Show detailed information about a Honeycomb logging endpoint on a Fastly service version").Alias("get") - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Input.ServiceVersion) - c.CmdClause.Flag("name", "The name of the Honeycomb logging object").Short('n').Required().StringVar(&c.Input.Name) - return &c -} - -// Exec invokes the application logic for the command. -func (c *DescribeCommand) Exec(in io.Reader, out io.Writer) error { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - c.Input.ServiceID = serviceID - - honeycomb, err := c.Globals.Client.GetHoneycomb(&c.Input) - if err != nil { - return err - } - - fmt.Fprintf(out, "Service ID: %s\n", honeycomb.ServiceID) - fmt.Fprintf(out, "Version: %d\n", honeycomb.ServiceVersion) - fmt.Fprintf(out, "Name: %s\n", honeycomb.Name) - fmt.Fprintf(out, "Dataset: %s\n", honeycomb.Dataset) - fmt.Fprintf(out, "Token: %s\n", honeycomb.Token) - fmt.Fprintf(out, "Format: %s\n", honeycomb.Format) - fmt.Fprintf(out, "Format version: %d\n", honeycomb.FormatVersion) - fmt.Fprintf(out, "Response condition: %s\n", honeycomb.ResponseCondition) - fmt.Fprintf(out, "Placement: %s\n", honeycomb.Placement) - - return nil -} diff --git a/pkg/logging/honeycomb/honeycomb_integration_test.go b/pkg/logging/honeycomb/honeycomb_integration_test.go deleted file mode 100644 index 3af0efab6..000000000 --- a/pkg/logging/honeycomb/honeycomb_integration_test.go +++ /dev/null @@ -1,381 +0,0 @@ -package honeycomb_test - -import ( - "bytes" - "errors" - "io" - "net/http" - "strings" - "testing" - - "github.com/fastly/cli/pkg/app" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/mock" - "github.com/fastly/cli/pkg/testutil" - "github.com/fastly/cli/pkg/update" - "github.com/fastly/go-fastly/v3/fastly" -) - -func TestHoneycombCreate(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "honeycomb", "create", "--service-id", "123", "--version", "1", "--name", "log", "--dataset", "log"}, - wantError: "error parsing arguments: required flag --auth-token not provided", - }, - { - args: []string{"logging", "honeycomb", "create", "--service-id", "123", "--version", "1", "--name", "log", "--auth-token", "abc"}, - wantError: "error parsing arguments: required flag --dataset not provided", - }, - { - args: []string{"logging", "honeycomb", "create", "--service-id", "123", "--version", "1", "--name", "log", "--auth-token", "abc", "--dataset", "log"}, - api: mock.API{CreateHoneycombFn: createHoneycombOK}, - wantOutput: "Created Honeycomb logging endpoint log (service 123 version 1)", - }, - { - args: []string{"logging", "honeycomb", "create", "--service-id", "123", "--version", "1", "--name", "log", "--auth-token", "abc", "--dataset", "log"}, - api: mock.API{CreateHoneycombFn: createHoneycombError}, - wantError: errTest.Error(), - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertStringContains(t, out.String(), testcase.wantOutput) - }) - } -} - -func TestHoneycombList(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "honeycomb", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListHoneycombsFn: listHoneycombsOK}, - wantOutput: listHoneycombsShortOutput, - }, - { - args: []string{"logging", "honeycomb", "list", "--service-id", "123", "--version", "1", "--verbose"}, - api: mock.API{ListHoneycombsFn: listHoneycombsOK}, - wantOutput: listHoneycombsVerboseOutput, - }, - { - args: []string{"logging", "honeycomb", "list", "--service-id", "123", "--version", "1", "-v"}, - api: mock.API{ListHoneycombsFn: listHoneycombsOK}, - wantOutput: listHoneycombsVerboseOutput, - }, - { - args: []string{"logging", "honeycomb", "--verbose", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListHoneycombsFn: listHoneycombsOK}, - wantOutput: listHoneycombsVerboseOutput, - }, - { - args: []string{"logging", "-v", "honeycomb", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListHoneycombsFn: listHoneycombsOK}, - wantOutput: listHoneycombsVerboseOutput, - }, - { - args: []string{"logging", "honeycomb", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListHoneycombsFn: listHoneycombsError}, - wantError: errTest.Error(), - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertString(t, testcase.wantOutput, out.String()) - }) - } -} - -func TestHoneycombDescribe(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "honeycomb", "describe", "--service-id", "123", "--version", "1"}, - wantError: "error parsing arguments: required flag --name not provided", - }, - { - args: []string{"logging", "honeycomb", "describe", "--service-id", "123", "--version", "1", "--name", "logs"}, - api: mock.API{GetHoneycombFn: getHoneycombError}, - wantError: errTest.Error(), - }, - { - args: []string{"logging", "honeycomb", "describe", "--service-id", "123", "--version", "1", "--name", "logs"}, - api: mock.API{GetHoneycombFn: getHoneycombOK}, - wantOutput: describeHoneycombOutput, - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertString(t, testcase.wantOutput, out.String()) - }) - } -} - -func TestHoneycombUpdate(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "honeycomb", "update", "--service-id", "123", "--version", "1", "--new-name", "log"}, - wantError: "error parsing arguments: required flag --name not provided", - }, - { - args: []string{"logging", "honeycomb", "update", "--service-id", "123", "--version", "1", "--name", "logs", "--new-name", "log"}, - api: mock.API{UpdateHoneycombFn: updateHoneycombError}, - wantError: errTest.Error(), - }, - { - args: []string{"logging", "honeycomb", "update", "--service-id", "123", "--version", "1", "--name", "logs", "--new-name", "log"}, - api: mock.API{UpdateHoneycombFn: updateHoneycombOK}, - wantOutput: "Updated Honeycomb logging endpoint log (service 123 version 1)", - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertStringContains(t, out.String(), testcase.wantOutput) - }) - } -} - -func TestHoneycombDelete(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "honeycomb", "delete", "--service-id", "123", "--version", "1"}, - wantError: "error parsing arguments: required flag --name not provided", - }, - { - args: []string{"logging", "honeycomb", "delete", "--service-id", "123", "--version", "1", "--name", "logs"}, - api: mock.API{DeleteHoneycombFn: deleteHoneycombError}, - wantError: errTest.Error(), - }, - { - args: []string{"logging", "honeycomb", "delete", "--service-id", "123", "--version", "1", "--name", "logs"}, - api: mock.API{DeleteHoneycombFn: deleteHoneycombOK}, - wantOutput: "Deleted Honeycomb logging endpoint logs (service 123 version 1)", - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertStringContains(t, out.String(), testcase.wantOutput) - }) - } -} - -var errTest = errors.New("fixture error") - -func createHoneycombOK(i *fastly.CreateHoneycombInput) (*fastly.Honeycomb, error) { - s := fastly.Honeycomb{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - } - - if i.Name != "" { - s.Name = i.Name - } - - return &s, nil -} - -func createHoneycombError(i *fastly.CreateHoneycombInput) (*fastly.Honeycomb, error) { - return nil, errTest -} - -func listHoneycombsOK(i *fastly.ListHoneycombsInput) ([]*fastly.Honeycomb, error) { - return []*fastly.Honeycomb{ - { - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "logs", - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - Dataset: "log", - Token: "tkn", - ResponseCondition: "Prevent default logging", - Placement: "none", - }, - { - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "analytics", - Dataset: "log", - Token: "tkn", - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - ResponseCondition: "Prevent default logging", - Placement: "none", - }, - }, nil -} - -func listHoneycombsError(i *fastly.ListHoneycombsInput) ([]*fastly.Honeycomb, error) { - return nil, errTest -} - -var listHoneycombsShortOutput = strings.TrimSpace(` -SERVICE VERSION NAME -123 1 logs -123 1 analytics -`) + "\n" - -var listHoneycombsVerboseOutput = strings.TrimSpace(` -Fastly API token not provided -Fastly API endpoint: https://api.fastly.com -Service ID: 123 -Version: 1 - Honeycomb 1/2 - Service ID: 123 - Version: 1 - Name: logs - Dataset: log - Token: tkn - Format: %h %l %u %t "%r" %>s %b - Format version: 2 - Response condition: Prevent default logging - Placement: none - Honeycomb 2/2 - Service ID: 123 - Version: 1 - Name: analytics - Dataset: log - Token: tkn - Format: %h %l %u %t "%r" %>s %b - Format version: 2 - Response condition: Prevent default logging - Placement: none -`) + "\n\n" - -func getHoneycombOK(i *fastly.GetHoneycombInput) (*fastly.Honeycomb, error) { - return &fastly.Honeycomb{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "logs", - Dataset: "log", - Token: "tkn", - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - ResponseCondition: "Prevent default logging", - Placement: "none", - }, nil -} - -func getHoneycombError(i *fastly.GetHoneycombInput) (*fastly.Honeycomb, error) { - return nil, errTest -} - -var describeHoneycombOutput = strings.TrimSpace(` -Service ID: 123 -Version: 1 -Name: logs -Dataset: log -Token: tkn -Format: %h %l %u %t "%r" %>s %b -Format version: 2 -Response condition: Prevent default logging -Placement: none -`) + "\n" - -func updateHoneycombOK(i *fastly.UpdateHoneycombInput) (*fastly.Honeycomb, error) { - return &fastly.Honeycomb{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "log", - Dataset: "log", - Token: "tkn", - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - ResponseCondition: "Prevent default logging", - Placement: "none", - }, nil -} - -func updateHoneycombError(i *fastly.UpdateHoneycombInput) (*fastly.Honeycomb, error) { - return nil, errTest -} - -func deleteHoneycombOK(i *fastly.DeleteHoneycombInput) error { - return nil -} - -func deleteHoneycombError(i *fastly.DeleteHoneycombInput) error { - return errTest -} diff --git a/pkg/logging/honeycomb/honeycomb_test.go b/pkg/logging/honeycomb/honeycomb_test.go deleted file mode 100644 index f22593f5a..000000000 --- a/pkg/logging/honeycomb/honeycomb_test.go +++ /dev/null @@ -1,188 +0,0 @@ -package honeycomb - -import ( - "testing" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/mock" - "github.com/fastly/cli/pkg/testutil" - "github.com/fastly/go-fastly/v3/fastly" -) - -func TestCreateHoneycombInput(t *testing.T) { - for _, testcase := range []struct { - name string - cmd *CreateCommand - want *fastly.CreateHoneycombInput - wantError string - }{ - { - name: "required values set flag serviceID", - cmd: createCommandRequired(), - want: &fastly.CreateHoneycombInput{ - ServiceID: "123", - ServiceVersion: 2, - Name: "log", - Token: "tkn", - Dataset: "logs", - }, - }, - { - name: "all values set flag serviceID", - cmd: createCommandAll(), - want: &fastly.CreateHoneycombInput{ - ServiceID: "123", - ServiceVersion: 2, - Name: "log", - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - Token: "tkn", - Dataset: "logs", - ResponseCondition: "Prevent default logging", - Placement: "none", - }, - }, - { - name: "error missing serviceID", - cmd: createCommandMissingServiceID(), - want: nil, - wantError: errors.ErrNoServiceID.Error(), - }, - } { - t.Run(testcase.name, func(t *testing.T) { - have, err := testcase.cmd.createInput() - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertEqual(t, testcase.want, have) - }) - } -} - -func TestUpdateHoneycombInput(t *testing.T) { - for _, testcase := range []struct { - name string - cmd *UpdateCommand - api mock.API - want *fastly.UpdateHoneycombInput - wantError string - }{ - { - name: "no updates", - cmd: updateCommandNoUpdates(), - api: mock.API{GetHoneycombFn: getHoneycombOK}, - want: &fastly.UpdateHoneycombInput{ - ServiceID: "123", - ServiceVersion: 2, - Name: "log", - }, - }, - { - name: "all values set flag serviceID", - cmd: updateCommandAll(), - api: mock.API{GetHoneycombFn: getHoneycombOK}, - want: &fastly.UpdateHoneycombInput{ - ServiceID: "123", - ServiceVersion: 2, - Name: "log", - NewName: fastly.String("new1"), - Format: fastly.String("new2"), - FormatVersion: fastly.Uint(3), - Token: fastly.String("new3"), - Dataset: fastly.String("new4"), - ResponseCondition: fastly.String("new5"), - Placement: fastly.String("new6"), - }, - }, - { - name: "error missing serviceID", - cmd: updateCommandMissingServiceID(), - want: nil, - wantError: errors.ErrNoServiceID.Error(), - }, - } { - t.Run(testcase.name, func(t *testing.T) { - testcase.cmd.Base.Globals.Client = testcase.api - - have, err := testcase.cmd.createInput() - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertEqual(t, testcase.want, have) - }) - } -} - -func createCommandRequired() *CreateCommand { - return &CreateCommand{ - manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, - EndpointName: "log", - Token: "tkn", - Dataset: "logs", - Version: 2, - } -} - -func createCommandAll() *CreateCommand { - return &CreateCommand{ - manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, - EndpointName: "log", - Token: "tkn", - Dataset: "logs", - Version: 2, - Format: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: `%h %l %u %t "%r" %>s %b`}, - FormatVersion: common.OptionalUint{Optional: common.Optional{WasSet: true}, Value: 2}, - ResponseCondition: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "Prevent default logging"}, - Placement: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "none"}, - } -} - -func createCommandMissingServiceID() *CreateCommand { - res := createCommandAll() - res.manifest = manifest.Data{} - return res -} - -func updateCommandNoUpdates() *UpdateCommand { - return &UpdateCommand{ - Base: common.Base{Globals: &config.Data{Client: nil}}, - manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, - EndpointName: "log", - Version: 2, - } -} - -func updateCommandAll() *UpdateCommand { - return &UpdateCommand{ - Base: common.Base{Globals: &config.Data{Client: nil}}, - manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, - EndpointName: "log", - Version: 2, - NewName: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new1"}, - Format: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new2"}, - FormatVersion: common.OptionalUint{Optional: common.Optional{WasSet: true}, Value: 3}, - Token: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new3"}, - Dataset: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new4"}, - ResponseCondition: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new5"}, - Placement: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new6"}, - } -} - -func updateCommandMissingServiceID() *UpdateCommand { - res := updateCommandAll() - res.manifest = manifest.Data{} - return res -} - -func getHoneycombOK(i *fastly.GetHoneycombInput) (*fastly.Honeycomb, error) { - return &fastly.Honeycomb{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "logs", - Token: "tkn", - Dataset: "logs", - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - ResponseCondition: "Prevent default logging", - Placement: "none", - }, nil -} diff --git a/pkg/logging/honeycomb/list.go b/pkg/logging/honeycomb/list.go deleted file mode 100644 index 64eb522cd..000000000 --- a/pkg/logging/honeycomb/list.go +++ /dev/null @@ -1,74 +0,0 @@ -package honeycomb - -import ( - "fmt" - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// ListCommand calls the Fastly API to list Honeycomb logging endpoints. -type ListCommand struct { - common.Base - manifest manifest.Data - Input fastly.ListHoneycombsInput -} - -// NewListCommand returns a usable command registered under the parent. -func NewListCommand(parent common.Registerer, globals *config.Data) *ListCommand { - var c ListCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("list", "List Honeycomb endpoints on a Fastly service version") - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Input.ServiceVersion) - return &c -} - -// Exec invokes the application logic for the command. -func (c *ListCommand) Exec(in io.Reader, out io.Writer) error { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - c.Input.ServiceID = serviceID - - honeycombs, err := c.Globals.Client.ListHoneycombs(&c.Input) - if err != nil { - return err - } - - if !c.Globals.Verbose() { - tw := text.NewTable(out) - tw.AddHeader("SERVICE", "VERSION", "NAME") - for _, honeycomb := range honeycombs { - tw.AddLine(honeycomb.ServiceID, honeycomb.ServiceVersion, honeycomb.Name) - } - tw.Print() - return nil - } - - fmt.Fprintf(out, "Service ID: %s\n", c.Input.ServiceID) - fmt.Fprintf(out, "Version: %d\n", c.Input.ServiceVersion) - for i, honeycomb := range honeycombs { - fmt.Fprintf(out, "\tHoneycomb %d/%d\n", i+1, len(honeycombs)) - fmt.Fprintf(out, "\t\tService ID: %s\n", honeycomb.ServiceID) - fmt.Fprintf(out, "\t\tVersion: %d\n", honeycomb.ServiceVersion) - fmt.Fprintf(out, "\t\tName: %s\n", honeycomb.Name) - fmt.Fprintf(out, "\t\tDataset: %s\n", honeycomb.Dataset) - fmt.Fprintf(out, "\t\tToken: %s\n", honeycomb.Token) - fmt.Fprintf(out, "\t\tFormat: %s\n", honeycomb.Format) - fmt.Fprintf(out, "\t\tFormat version: %d\n", honeycomb.FormatVersion) - fmt.Fprintf(out, "\t\tResponse condition: %s\n", honeycomb.ResponseCondition) - fmt.Fprintf(out, "\t\tPlacement: %s\n", honeycomb.Placement) - } - fmt.Fprintln(out) - - return nil -} diff --git a/pkg/logging/honeycomb/root.go b/pkg/logging/honeycomb/root.go deleted file mode 100644 index b65b46a86..000000000 --- a/pkg/logging/honeycomb/root.go +++ /dev/null @@ -1,28 +0,0 @@ -package honeycomb - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/config" -) - -// RootCommand is the parent command for all subcommands in this package. -// It should be installed under the primary root command. -type RootCommand struct { - common.Base - // no flags -} - -// NewRootCommand returns a new command registered in the parent. -func NewRootCommand(parent common.Registerer, globals *config.Data) *RootCommand { - var c RootCommand - c.Globals = globals - c.CmdClause = parent.Command("honeycomb", "Manipulate Fastly service version Honeycomb logging endpoints") - return &c -} - -// Exec implements the command interface. -func (c *RootCommand) Exec(in io.Reader, out io.Writer) error { - panic("unreachable") -} diff --git a/pkg/logging/honeycomb/update.go b/pkg/logging/honeycomb/update.go deleted file mode 100644 index 137e80b71..000000000 --- a/pkg/logging/honeycomb/update.go +++ /dev/null @@ -1,115 +0,0 @@ -package honeycomb - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// UpdateCommand calls the Fastly API to update a Honeycomb logging endpoint. -type UpdateCommand struct { - common.Base - manifest manifest.Data - - // required - EndpointName string // Can't shadow common.Base method Name(). - Version int - - // optional - NewName common.OptionalString - Format common.OptionalString - FormatVersion common.OptionalUint - Dataset common.OptionalString - Token common.OptionalString - ResponseCondition common.OptionalString - Placement common.OptionalString -} - -// NewUpdateCommand returns a usable command registered under the parent. -func NewUpdateCommand(parent common.Registerer, globals *config.Data) *UpdateCommand { - var c UpdateCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - - c.CmdClause = parent.Command("update", "Update a Honeycomb logging endpoint on a Fastly service version") - - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Version) - c.CmdClause.Flag("name", "The name of the Honeycomb logging object").Short('n').Required().StringVar(&c.EndpointName) - - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("new-name", "New name of the Honeycomb logging object").Action(c.NewName.Set).StringVar(&c.NewName.Value) - c.CmdClause.Flag("format", "Apache style log formatting. Your log must produce valid JSON that Honeycomb can ingest").Action(c.Format.Set).StringVar(&c.Format.Value) - c.CmdClause.Flag("format-version", "The version of the custom logging format used for the configured endpoint. Can be either 2 (default) or 1").Action(c.FormatVersion.Set).UintVar(&c.FormatVersion.Value) - c.CmdClause.Flag("dataset", "The Honeycomb Dataset you want to log to").Action(c.Dataset.Set).StringVar(&c.Dataset.Value) - c.CmdClause.Flag("auth-token", "The Write Key from the Account page of your Honeycomb account").Action(c.Token.Set).StringVar(&c.Token.Value) - c.CmdClause.Flag("response-condition", "The name of an existing condition in the configured endpoint, or leave blank to always execute").Action(c.ResponseCondition.Set).StringVar(&c.ResponseCondition.Value) - c.CmdClause.Flag("placement", "Where in the generated VCL the logging call should be placed, overriding any format_version default. Can be none or waf_debug").Action(c.Placement.Set).StringVar(&c.Placement.Value) - - return &c -} - -// createInput transforms values parsed from CLI flags into an object to be used by the API client library. -func (c *UpdateCommand) createInput() (*fastly.UpdateHoneycombInput, error) { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return nil, errors.ErrNoServiceID - } - - input := fastly.UpdateHoneycombInput{ - ServiceID: serviceID, - ServiceVersion: c.Version, - Name: c.EndpointName, - } - - if c.NewName.WasSet { - input.NewName = fastly.String(c.NewName.Value) - } - - if c.Format.WasSet { - input.Format = fastly.String(c.Format.Value) - } - - if c.FormatVersion.WasSet { - input.FormatVersion = fastly.Uint(c.FormatVersion.Value) - } - - if c.Token.WasSet { - input.Token = fastly.String(c.Token.Value) - } - - if c.Dataset.WasSet { - input.Dataset = fastly.String(c.Dataset.Value) - } - - if c.ResponseCondition.WasSet { - input.ResponseCondition = fastly.String(c.ResponseCondition.Value) - } - - if c.Placement.WasSet { - input.Placement = fastly.String(c.Placement.Value) - } - - return &input, nil -} - -// Exec invokes the application logic for the command. -func (c *UpdateCommand) Exec(in io.Reader, out io.Writer) error { - input, err := c.createInput() - if err != nil { - return err - } - - honeycomb, err := c.Globals.Client.UpdateHoneycomb(input) - if err != nil { - return err - } - - text.Success(out, "Updated Honeycomb logging endpoint %s (service %s version %d)", honeycomb.Name, honeycomb.ServiceID, honeycomb.ServiceVersion) - return nil -} diff --git a/pkg/logging/https/create.go b/pkg/logging/https/create.go deleted file mode 100644 index 6900adf73..000000000 --- a/pkg/logging/https/create.go +++ /dev/null @@ -1,171 +0,0 @@ -package https - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// CreateCommand calls the Fastly API to create an HTTPS logging endpoint. -type CreateCommand struct { - common.Base - manifest manifest.Data - - // required - EndpointName string // Can't shadow common.Base method Name(). - Version int - URL string - - // optional - RequestMaxEntries common.OptionalUint - RequestMaxBytes common.OptionalUint - TLSCACert common.OptionalString - TLSClientCert common.OptionalString - TLSClientKey common.OptionalString - TLSHostname common.OptionalString - MessageType common.OptionalString - ContentType common.OptionalString - HeaderName common.OptionalString - HeaderValue common.OptionalString - Method common.OptionalString - JSONFormat common.OptionalString - Format common.OptionalString - FormatVersion common.OptionalUint - Placement common.OptionalString - ResponseCondition common.OptionalString -} - -// NewCreateCommand returns a usable command registered under the parent. -func NewCreateCommand(parent common.Registerer, globals *config.Data) *CreateCommand { - var c CreateCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("create", "Create an HTTPS logging endpoint on a Fastly service version").Alias("add") - - c.CmdClause.Flag("name", "The name of the HTTPS logging object. Used as a primary key for API access").Short('n').Required().StringVar(&c.EndpointName) - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Version) - c.CmdClause.Flag("url", "URL that log data will be sent to. Must use the https protocol").Required().StringVar(&c.URL) - - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("content-type", "Content type of the header sent with the request").Action(c.ContentType.Set).StringVar(&c.ContentType.Value) - c.CmdClause.Flag("header-name", "Name of the custom header sent with the request").Action(c.HeaderName.Set).StringVar(&c.HeaderName.Value) - c.CmdClause.Flag("header-value", "Value of the custom header sent with the request").Action(c.HeaderValue.Set).StringVar(&c.HeaderValue.Value) - c.CmdClause.Flag("method", "HTTP method used for request. Can be POST or PUT. Defaults to POST if not specified").Action(c.Method.Set).StringVar(&c.Method.Value) - c.CmdClause.Flag("json-format", "Enforces valid JSON formatting for log entries. Can be disabled 0, array of json (wraps JSON log batches in an array) 1, or newline delimited json (places each JSON log entry onto a new line in a batch) 2").Action(c.JSONFormat.Set).StringVar(&c.JSONFormat.Value) - c.CmdClause.Flag("tls-ca-cert", "A secure certificate to authenticate the server with. Must be in PEM format").Action(c.TLSCACert.Set).StringVar(&c.TLSCACert.Value) - c.CmdClause.Flag("tls-client-cert", "The client certificate used to make authenticated requests. Must be in PEM format").Action(c.TLSClientCert.Set).StringVar(&c.TLSClientCert.Value) - c.CmdClause.Flag("tls-client-key", "The client private key used to make authenticated requests. Must be in PEM format").Action(c.TLSClientKey.Set).StringVar(&c.TLSClientKey.Value) - c.CmdClause.Flag("tls-hostname", "The hostname used to verify the server's certificate. It can either be the Common Name or a Subject Alternative Name (SAN)").Action(c.TLSHostname.Set).StringVar(&c.TLSHostname.Value) - c.CmdClause.Flag("message-type", "How the message should be formatted. One of: classic (default), loggly, logplex or blank").Action(c.MessageType.Set).StringVar(&c.MessageType.Value) - c.CmdClause.Flag("format", "Apache style log formatting. Your log must produce valid JSON that HTTPS can ingest").Action(c.Format.Set).StringVar(&c.Format.Value) - c.CmdClause.Flag("format-version", "The version of the custom logging format used for the configured endpoint. Can be either 2 (default) or 1").Action(c.FormatVersion.Set).UintVar(&c.FormatVersion.Value) - c.CmdClause.Flag("placement", "Where in the generated VCL the logging call should be placed, overriding any format_version default. Can be none or waf_debug").Action(c.Placement.Set).StringVar(&c.Placement.Value) - c.CmdClause.Flag("response-condition", "The name of an existing condition in the configured endpoint, or leave blank to always execute").Action(c.ResponseCondition.Set).StringVar(&c.ResponseCondition.Value) - c.CmdClause.Flag("request-max-entries", "Maximum number of logs to append to a batch, if non-zero. Defaults to 0 for unbounded").Action(c.RequestMaxEntries.Set).UintVar(&c.RequestMaxEntries.Value) - c.CmdClause.Flag("request-max-bytes", "Maximum size of log batch, if non-zero. Defaults to 0 for unbounded").Action(c.RequestMaxBytes.Set).UintVar(&c.RequestMaxBytes.Value) - - return &c -} - -// createInput transforms values parsed from CLI flags into an object to be used by the API client library. -func (c *CreateCommand) createInput() (*fastly.CreateHTTPSInput, error) { - var input fastly.CreateHTTPSInput - - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return nil, errors.ErrNoServiceID - } - - input.ServiceID = serviceID - input.ServiceVersion = c.Version - input.Name = c.EndpointName - input.URL = c.URL - - if c.ContentType.WasSet { - input.ContentType = c.ContentType.Value - } - - if c.HeaderName.WasSet { - input.HeaderName = c.HeaderName.Value - } - - if c.HeaderValue.WasSet { - input.HeaderValue = c.HeaderValue.Value - } - - if c.Method.WasSet { - input.Method = c.Method.Value - } - - if c.JSONFormat.WasSet { - input.JSONFormat = c.JSONFormat.Value - } - - if c.RequestMaxEntries.WasSet { - input.RequestMaxEntries = c.RequestMaxEntries.Value - } - - if c.RequestMaxBytes.WasSet { - input.RequestMaxBytes = c.RequestMaxBytes.Value - } - - if c.TLSCACert.WasSet { - input.TLSCACert = c.TLSCACert.Value - } - - if c.TLSClientCert.WasSet { - input.TLSClientCert = c.TLSClientCert.Value - } - - if c.TLSClientKey.WasSet { - input.TLSClientKey = c.TLSClientKey.Value - } - - if c.TLSHostname.WasSet { - input.TLSHostname = c.TLSHostname.Value - } - - if c.Format.WasSet { - input.Format = c.Format.Value - } - - if c.FormatVersion.WasSet { - input.FormatVersion = c.FormatVersion.Value - } - - if c.ResponseCondition.WasSet { - input.ResponseCondition = c.ResponseCondition.Value - } - - if c.Placement.WasSet { - input.Placement = c.Placement.Value - } - - if c.MessageType.WasSet { - input.MessageType = c.MessageType.Value - } - - return &input, nil -} - -// Exec invokes the application logic for the command. -func (c *CreateCommand) Exec(in io.Reader, out io.Writer) error { - input, err := c.createInput() - if err != nil { - return err - } - - d, err := c.Globals.Client.CreateHTTPS(input) - if err != nil { - return err - } - - text.Success(out, "Created HTTPS logging endpoint %s (service %s version %d)", d.Name, d.ServiceID, d.ServiceVersion) - return nil -} diff --git a/pkg/logging/https/delete.go b/pkg/logging/https/delete.go deleted file mode 100644 index e3f5c85a7..000000000 --- a/pkg/logging/https/delete.go +++ /dev/null @@ -1,51 +0,0 @@ -package https - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// DeleteCommand calls the Fastly API to delete an HTTPS logging endpoint. -type DeleteCommand struct { - common.Base - manifest manifest.Data - Input fastly.DeleteHTTPSInput -} - -// NewDeleteCommand returns a usable command registered under the parent. -func NewDeleteCommand(parent common.Registerer, globals *config.Data) *DeleteCommand { - var c DeleteCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("delete", "Delete an HTTPS logging endpoint on a Fastly service version").Alias("remove") - - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Input.ServiceVersion) - c.CmdClause.Flag("name", "The name of the HTTPS logging object").Short('n').Required().StringVar(&c.Input.Name) - - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - - return &c -} - -// Exec invokes the application logic for the command. -func (c *DeleteCommand) Exec(in io.Reader, out io.Writer) error { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - c.Input.ServiceID = serviceID - - if err := c.Globals.Client.DeleteHTTPS(&c.Input); err != nil { - return err - } - - text.Success(out, "Deleted HTTPS logging endpoint %s (service %s version %d)", c.Input.Name, c.Input.ServiceID, c.Input.ServiceVersion) - return nil -} diff --git a/pkg/logging/https/describe.go b/pkg/logging/https/describe.go deleted file mode 100644 index bc472d9f2..000000000 --- a/pkg/logging/https/describe.go +++ /dev/null @@ -1,69 +0,0 @@ -package https - -import ( - "fmt" - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/go-fastly/v3/fastly" -) - -// DescribeCommand calls the Fastly API to describe an HTTPS logging endpoint. -type DescribeCommand struct { - common.Base - manifest manifest.Data - Input fastly.GetHTTPSInput -} - -// NewDescribeCommand returns a usable command registered under the parent. -func NewDescribeCommand(parent common.Registerer, globals *config.Data) *DescribeCommand { - var c DescribeCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("describe", "Show detailed information about an HTTPS logging endpoint on a Fastly service version").Alias("get") - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Input.ServiceVersion) - c.CmdClause.Flag("name", "The name of the HTTPS logging object").Short('n').Required().StringVar(&c.Input.Name) - return &c -} - -// Exec invokes the application logic for the command. -func (c *DescribeCommand) Exec(in io.Reader, out io.Writer) error { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - c.Input.ServiceID = serviceID - - https, err := c.Globals.Client.GetHTTPS(&c.Input) - if err != nil { - return err - } - - fmt.Fprintf(out, "Service ID: %s\n", https.ServiceID) - fmt.Fprintf(out, "Version: %d\n", https.ServiceVersion) - fmt.Fprintf(out, "Name: %s\n", https.Name) - fmt.Fprintf(out, "URL: %s\n", https.URL) - fmt.Fprintf(out, "Content type: %s\n", https.ContentType) - fmt.Fprintf(out, "Header name: %s\n", https.HeaderName) - fmt.Fprintf(out, "Header value: %s\n", https.HeaderValue) - fmt.Fprintf(out, "Method: %s\n", https.Method) - fmt.Fprintf(out, "JSON format: %s\n", https.JSONFormat) - fmt.Fprintf(out, "TLS CA certificate: %s\n", https.TLSCACert) - fmt.Fprintf(out, "TLS client certificate: %s\n", https.TLSClientCert) - fmt.Fprintf(out, "TLS client key: %s\n", https.TLSClientKey) - fmt.Fprintf(out, "TLS hostname: %s\n", https.TLSHostname) - fmt.Fprintf(out, "Request max entries: %d\n", https.RequestMaxEntries) - fmt.Fprintf(out, "Request max bytes: %d\n", https.RequestMaxBytes) - fmt.Fprintf(out, "Message type: %s\n", https.MessageType) - fmt.Fprintf(out, "Format: %s\n", https.Format) - fmt.Fprintf(out, "Format version: %d\n", https.FormatVersion) - fmt.Fprintf(out, "Response condition: %s\n", https.ResponseCondition) - fmt.Fprintf(out, "Placement: %s\n", https.Placement) - - return nil -} diff --git a/pkg/logging/https/https_integration_test.go b/pkg/logging/https/https_integration_test.go deleted file mode 100644 index ce557c543..000000000 --- a/pkg/logging/https/https_integration_test.go +++ /dev/null @@ -1,466 +0,0 @@ -package https_test - -import ( - "bytes" - "errors" - "io" - "net/http" - "strings" - "testing" - - "github.com/fastly/cli/pkg/app" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/mock" - "github.com/fastly/cli/pkg/testutil" - "github.com/fastly/cli/pkg/update" - "github.com/fastly/go-fastly/v3/fastly" -) - -func TestHTTPSCreate(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "https", "create", "--service-id", "123", "--version", "1", "--name", "log"}, - wantError: "error parsing arguments: required flag --url not provided", - }, - { - args: []string{"logging", "https", "create", "--service-id", "123", "--version", "1", "--name", "log", "--url", "example.com"}, - api: mock.API{CreateHTTPSFn: createHTTPSOK}, - wantOutput: "Created HTTPS logging endpoint log (service 123 version 1)", - }, - { - args: []string{"logging", "https", "create", "--service-id", "123", "--version", "1", "--name", "log", "--url", "example.com"}, - api: mock.API{CreateHTTPSFn: createHTTPSError}, - wantError: errTest.Error(), - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertStringContains(t, out.String(), testcase.wantOutput) - }) - } -} - -func TestHTTPSList(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "https", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListHTTPSFn: listHTTPSsOK}, - wantOutput: listHTTPSsShortOutput, - }, - { - args: []string{"logging", "https", "list", "--service-id", "123", "--version", "1", "--verbose"}, - api: mock.API{ListHTTPSFn: listHTTPSsOK}, - wantOutput: listHTTPSsVerboseOutput, - }, - { - args: []string{"logging", "https", "list", "--service-id", "123", "--version", "1", "-v"}, - api: mock.API{ListHTTPSFn: listHTTPSsOK}, - wantOutput: listHTTPSsVerboseOutput, - }, - { - args: []string{"logging", "https", "--verbose", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListHTTPSFn: listHTTPSsOK}, - wantOutput: listHTTPSsVerboseOutput, - }, - { - args: []string{"logging", "-v", "https", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListHTTPSFn: listHTTPSsOK}, - wantOutput: listHTTPSsVerboseOutput, - }, - { - args: []string{"logging", "https", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListHTTPSFn: listHTTPSsError}, - wantError: errTest.Error(), - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertString(t, testcase.wantOutput, out.String()) - }) - } -} - -func TestHTTPSDescribe(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "https", "describe", "--service-id", "123", "--version", "1"}, - wantError: "error parsing arguments: required flag --name not provided", - }, - { - args: []string{"logging", "https", "describe", "--service-id", "123", "--version", "1", "--name", "logs"}, - api: mock.API{GetHTTPSFn: getHTTPSError}, - wantError: errTest.Error(), - }, - { - args: []string{"logging", "https", "describe", "--service-id", "123", "--version", "1", "--name", "logs"}, - api: mock.API{GetHTTPSFn: getHTTPSOK}, - wantOutput: describeHTTPSOutput, - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertString(t, testcase.wantOutput, out.String()) - }) - } -} - -func TestHTTPSUpdate(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "https", "update", "--service-id", "123", "--version", "1", "--new-name", "log"}, - wantError: "error parsing arguments: required flag --name not provided", - }, - { - args: []string{"logging", "https", "update", "--service-id", "123", "--version", "1", "--name", "logs", "--new-name", "log"}, - api: mock.API{UpdateHTTPSFn: updateHTTPSError}, - wantError: errTest.Error(), - }, - { - args: []string{"logging", "https", "update", "--service-id", "123", "--version", "1", "--name", "logs", "--new-name", "log"}, - api: mock.API{UpdateHTTPSFn: updateHTTPSOK}, - wantOutput: "Updated HTTPS logging endpoint log (service 123 version 1)", - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertStringContains(t, out.String(), testcase.wantOutput) - }) - } -} - -func TestHTTPSDelete(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "https", "delete", "--service-id", "123", "--version", "1"}, - wantError: "error parsing arguments: required flag --name not provided", - }, - { - args: []string{"logging", "https", "delete", "--service-id", "123", "--version", "1", "--name", "logs"}, - api: mock.API{DeleteHTTPSFn: deleteHTTPSError}, - wantError: errTest.Error(), - }, - { - args: []string{"logging", "https", "delete", "--service-id", "123", "--version", "1", "--name", "logs"}, - api: mock.API{DeleteHTTPSFn: deleteHTTPSOK}, - wantOutput: "Deleted HTTPS logging endpoint logs (service 123 version 1)", - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertStringContains(t, out.String(), testcase.wantOutput) - }) - } -} - -var errTest = errors.New("fixture error") - -func createHTTPSOK(i *fastly.CreateHTTPSInput) (*fastly.HTTPS, error) { - return &fastly.HTTPS{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "log", - ResponseCondition: "Prevent default logging", - Format: `%h %l %u %t "%r" %>s %b`, - URL: "example.com", - RequestMaxEntries: 2, - RequestMaxBytes: 2, - ContentType: "application/json", - HeaderName: "name", - HeaderValue: "value", - Method: "GET", - JSONFormat: "1", - Placement: "none", - TLSCACert: "-----BEGIN CERTIFICATE-----foo", - TLSClientCert: "-----BEGIN CERTIFICATE-----bar", - TLSClientKey: "-----BEGIN PRIVATE KEY-----bar", - TLSHostname: "example.com", - MessageType: "classic", - FormatVersion: 2, - }, nil -} - -func createHTTPSError(i *fastly.CreateHTTPSInput) (*fastly.HTTPS, error) { - return nil, errTest -} - -func listHTTPSsOK(i *fastly.ListHTTPSInput) ([]*fastly.HTTPS, error) { - return []*fastly.HTTPS{ - { - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "logs", - ResponseCondition: "Prevent default logging", - Format: `%h %l %u %t "%r" %>s %b`, - URL: "example.com", - RequestMaxEntries: 2, - RequestMaxBytes: 2, - ContentType: "application/json", - HeaderName: "name", - HeaderValue: "value", - Method: "GET", - JSONFormat: "1", - Placement: "none", - TLSCACert: "-----BEGIN CERTIFICATE-----foo", - TLSClientCert: "-----BEGIN CERTIFICATE-----bar", - TLSClientKey: "-----BEGIN PRIVATE KEY-----bar", - TLSHostname: "example.com", - MessageType: "classic", - FormatVersion: 2, - }, - { - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "analytics", - ResponseCondition: "Prevent default logging", - Format: `%h %l %u %t "%r" %>s %b`, - URL: "analytics.example.com", - RequestMaxEntries: 2, - RequestMaxBytes: 2, - ContentType: "application/json", - HeaderName: "name", - HeaderValue: "value", - Method: "GET", - JSONFormat: "1", - Placement: "none", - TLSCACert: "-----BEGIN CERTIFICATE-----foo", - TLSClientCert: "-----BEGIN CERTIFICATE-----bar", - TLSClientKey: "-----BEGIN PRIVATE KEY-----bar", - TLSHostname: "example.com", - MessageType: "classic", - FormatVersion: 2, - }, - }, nil -} - -func listHTTPSsError(i *fastly.ListHTTPSInput) ([]*fastly.HTTPS, error) { - return nil, errTest -} - -var listHTTPSsShortOutput = strings.TrimSpace(` -SERVICE VERSION NAME -123 1 logs -123 1 analytics -`) + "\n" - -var listHTTPSsVerboseOutput = strings.TrimSpace(` -Fastly API token not provided -Fastly API endpoint: https://api.fastly.com -Service ID: 123 -Version: 1 - HTTPS 1/2 - Service ID: 123 - Version: 1 - Name: logs - URL: example.com - Content type: application/json - Header name: name - Header value: value - Method: GET - JSON format: 1 - TLS CA certificate: -----BEGIN CERTIFICATE-----foo - TLS client certificate: -----BEGIN CERTIFICATE-----bar - TLS client key: -----BEGIN PRIVATE KEY-----bar - TLS hostname: example.com - Request max entries: 2 - Request max bytes: 2 - Message type: classic - Format: %h %l %u %t "%r" %>s %b - Format version: 2 - Response condition: Prevent default logging - Placement: none - HTTPS 2/2 - Service ID: 123 - Version: 1 - Name: analytics - URL: analytics.example.com - Content type: application/json - Header name: name - Header value: value - Method: GET - JSON format: 1 - TLS CA certificate: -----BEGIN CERTIFICATE-----foo - TLS client certificate: -----BEGIN CERTIFICATE-----bar - TLS client key: -----BEGIN PRIVATE KEY-----bar - TLS hostname: example.com - Request max entries: 2 - Request max bytes: 2 - Message type: classic - Format: %h %l %u %t "%r" %>s %b - Format version: 2 - Response condition: Prevent default logging - Placement: none -`) + "\n\n" - -func getHTTPSOK(i *fastly.GetHTTPSInput) (*fastly.HTTPS, error) { - return &fastly.HTTPS{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "log", - ResponseCondition: "Prevent default logging", - Format: `%h %l %u %t "%r" %>s %b`, - URL: "example.com", - RequestMaxEntries: 2, - RequestMaxBytes: 2, - ContentType: "application/json", - HeaderName: "name", - HeaderValue: "value", - Method: "GET", - JSONFormat: "1", - Placement: "none", - TLSCACert: "-----BEGIN CERTIFICATE-----foo", - TLSClientCert: "-----BEGIN CERTIFICATE-----bar", - TLSClientKey: "-----BEGIN PRIVATE KEY-----bar", - TLSHostname: "example.com", - MessageType: "classic", - FormatVersion: 2, - }, nil -} - -func getHTTPSError(i *fastly.GetHTTPSInput) (*fastly.HTTPS, error) { - return nil, errTest -} - -var describeHTTPSOutput = strings.TrimSpace(` -Service ID: 123 -Version: 1 -Name: log -URL: example.com -Content type: application/json -Header name: name -Header value: value -Method: GET -JSON format: 1 -TLS CA certificate: -----BEGIN CERTIFICATE-----foo -TLS client certificate: -----BEGIN CERTIFICATE-----bar -TLS client key: -----BEGIN PRIVATE KEY-----bar -TLS hostname: example.com -Request max entries: 2 -Request max bytes: 2 -Message type: classic -Format: %h %l %u %t "%r" %>s %b -Format version: 2 -Response condition: Prevent default logging -Placement: none -`) + "\n" - -func updateHTTPSOK(i *fastly.UpdateHTTPSInput) (*fastly.HTTPS, error) { - return &fastly.HTTPS{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "log", - ResponseCondition: "Prevent default logging", - Format: `%h %l %u %t "%r" %>s %b`, - URL: "example.com", - RequestMaxEntries: 2, - RequestMaxBytes: 2, - ContentType: "application/json", - HeaderName: "name", - HeaderValue: "value", - Method: "GET", - JSONFormat: "1", - Placement: "none", - TLSCACert: "-----BEGIN CERTIFICATE-----foo", - TLSClientCert: "-----BEGIN CERTIFICATE-----bar", - TLSClientKey: "-----BEGIN PRIVATE KEY-----bar", - TLSHostname: "example.com", - MessageType: "classic", - FormatVersion: 2, - }, nil -} - -func updateHTTPSError(i *fastly.UpdateHTTPSInput) (*fastly.HTTPS, error) { - return nil, errTest -} - -func deleteHTTPSOK(i *fastly.DeleteHTTPSInput) error { - return nil -} - -func deleteHTTPSError(i *fastly.DeleteHTTPSInput) error { - return errTest -} diff --git a/pkg/logging/https/https_test.go b/pkg/logging/https/https_test.go deleted file mode 100644 index 2f2d78762..000000000 --- a/pkg/logging/https/https_test.go +++ /dev/null @@ -1,241 +0,0 @@ -package https - -import ( - "testing" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/mock" - "github.com/fastly/cli/pkg/testutil" - "github.com/fastly/go-fastly/v3/fastly" -) - -func TestCreateHTTPSInput(t *testing.T) { - for _, testcase := range []struct { - name string - cmd *CreateCommand - want *fastly.CreateHTTPSInput - wantError string - }{ - { - name: "required values set flag serviceID", - cmd: createCommandRequired(), - want: &fastly.CreateHTTPSInput{ - ServiceID: "123", - ServiceVersion: 2, - Name: "log", - URL: "example.com", - }, - }, - { - name: "all values set flag serviceID", - cmd: createCommandAll(), - want: &fastly.CreateHTTPSInput{ - ServiceID: "123", - ServiceVersion: 2, - Name: "logs", - ResponseCondition: "Prevent default logging", - Format: `%h %l %u %t "%r" %>s %b`, - URL: "example.com", - RequestMaxEntries: 2, - RequestMaxBytes: 2, - ContentType: "application/json", - HeaderName: "name", - HeaderValue: "value", - Method: "GET", - JSONFormat: "1", - Placement: "none", - TLSCACert: "-----BEGIN CERTIFICATE-----foo", - TLSClientCert: "-----BEGIN CERTIFICATE-----bar", - TLSClientKey: "-----BEGIN PRIVATE KEY-----bar", - TLSHostname: "example.com", - MessageType: "classic", - FormatVersion: 2, - }, - }, - { - name: "error missing serviceID", - cmd: createCommandMissingServiceID(), - want: nil, - wantError: errors.ErrNoServiceID.Error(), - }, - } { - t.Run(testcase.name, func(t *testing.T) { - have, err := testcase.cmd.createInput() - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertEqual(t, testcase.want, have) - }) - } -} - -func TestUpdateHTTPSInput(t *testing.T) { - for _, testcase := range []struct { - name string - cmd *UpdateCommand - api mock.API - want *fastly.UpdateHTTPSInput - wantError string - }{ - { - name: "all values set flag serviceID", - cmd: updateCommandAll(), - api: mock.API{GetHTTPSFn: getHTTPSOK}, - want: &fastly.UpdateHTTPSInput{ - ServiceID: "123", - ServiceVersion: 2, - Name: "log", - NewName: fastly.String("new1"), - ResponseCondition: fastly.String("new2"), - Format: fastly.String("new3"), - URL: fastly.String("new4"), - RequestMaxEntries: fastly.Uint(3), - RequestMaxBytes: fastly.Uint(3), - ContentType: fastly.String("new5"), - HeaderName: fastly.String("new6"), - HeaderValue: fastly.String("new7"), - Method: fastly.String("new8"), - JSONFormat: fastly.String("new9"), - Placement: fastly.String("new10"), - TLSCACert: fastly.String("new11"), - TLSClientCert: fastly.String("new12"), - TLSClientKey: fastly.String("new13"), - TLSHostname: fastly.String("new14"), - MessageType: fastly.String("new15"), - FormatVersion: fastly.Uint(3), - }, - }, - { - name: "no updates", - cmd: updateCommandNoUpdates(), - api: mock.API{GetHTTPSFn: getHTTPSOK}, - want: &fastly.UpdateHTTPSInput{ - ServiceID: "123", - ServiceVersion: 2, - Name: "log", - }, - }, - { - name: "error missing serviceID", - cmd: updateCommandMissingServiceID(), - want: nil, - wantError: errors.ErrNoServiceID.Error(), - }, - } { - t.Run(testcase.name, func(t *testing.T) { - testcase.cmd.Base.Globals.Client = testcase.api - - have, err := testcase.cmd.createInput() - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertEqual(t, testcase.want, have) - }) - } -} - -func createCommandRequired() *CreateCommand { - return &CreateCommand{ - manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, - EndpointName: "log", - Version: 2, - URL: "example.com", - } -} - -func createCommandAll() *CreateCommand { - return &CreateCommand{ - manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, - EndpointName: "logs", - Version: 2, - URL: "example.com", - ContentType: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "application/json"}, - HeaderName: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "name"}, - HeaderValue: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "value"}, - Method: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "GET"}, - JSONFormat: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "1"}, - MessageType: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "classic"}, - RequestMaxEntries: common.OptionalUint{Optional: common.Optional{WasSet: true}, Value: 2}, - RequestMaxBytes: common.OptionalUint{Optional: common.Optional{WasSet: true}, Value: 2}, - Format: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: `%h %l %u %t "%r" %>s %b`}, - FormatVersion: common.OptionalUint{Optional: common.Optional{WasSet: true}, Value: 2}, - ResponseCondition: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "Prevent default logging"}, - Placement: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "none"}, - TLSCACert: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "-----BEGIN CERTIFICATE-----foo"}, - TLSHostname: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "example.com"}, - TLSClientCert: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "-----BEGIN CERTIFICATE-----bar"}, - TLSClientKey: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "-----BEGIN PRIVATE KEY-----bar"}, - } -} - -func createCommandMissingServiceID() *CreateCommand { - res := createCommandAll() - res.manifest = manifest.Data{} - return res -} - -func updateCommandNoUpdates() *UpdateCommand { - return &UpdateCommand{ - Base: common.Base{Globals: &config.Data{Client: nil}}, - manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, - EndpointName: "log", - Version: 2, - } -} - -func updateCommandAll() *UpdateCommand { - return &UpdateCommand{ - Base: common.Base{Globals: &config.Data{Client: nil}}, - manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, - EndpointName: "log", - Version: 2, - NewName: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new1"}, - ResponseCondition: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new2"}, - Format: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new3"}, - URL: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new4"}, - ContentType: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new5"}, - HeaderName: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new6"}, - HeaderValue: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new7"}, - Method: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new8"}, - JSONFormat: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new9"}, - Placement: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new10"}, - RequestMaxEntries: common.OptionalUint{Optional: common.Optional{WasSet: true}, Value: 3}, - RequestMaxBytes: common.OptionalUint{Optional: common.Optional{WasSet: true}, Value: 3}, - TLSCACert: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new11"}, - TLSClientCert: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new12"}, - TLSClientKey: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new13"}, - TLSHostname: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new14"}, - MessageType: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new15"}, - FormatVersion: common.OptionalUint{Optional: common.Optional{WasSet: true}, Value: 3}, - } -} - -func updateCommandMissingServiceID() *UpdateCommand { - res := updateCommandAll() - res.manifest = manifest.Data{} - return res -} - -func getHTTPSOK(i *fastly.GetHTTPSInput) (*fastly.HTTPS, error) { - return &fastly.HTTPS{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "log", - ResponseCondition: "Prevent default logging", - Format: `%h %l %u %t "%r" %>s %b`, - URL: "example.com", - RequestMaxEntries: 2, - RequestMaxBytes: 2, - ContentType: "application/json", - HeaderName: "name", - HeaderValue: "value", - Method: "GET", - JSONFormat: "1", - Placement: "none", - TLSCACert: "-----BEGIN CERTIFICATE-----foo", - TLSClientCert: "-----BEGIN CERTIFICATE-----bar", - TLSClientKey: "-----BEGIN PRIVATE KEY-----bar", - TLSHostname: "example.com", - MessageType: "classic", - FormatVersion: 2, - }, nil -} diff --git a/pkg/logging/https/list.go b/pkg/logging/https/list.go deleted file mode 100644 index 967d142e8..000000000 --- a/pkg/logging/https/list.go +++ /dev/null @@ -1,85 +0,0 @@ -package https - -import ( - "fmt" - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// ListCommand calls the Fastly API to list HTTPS logging endpoints. -type ListCommand struct { - common.Base - manifest manifest.Data - Input fastly.ListHTTPSInput -} - -// NewListCommand returns a usable command registered under the parent. -func NewListCommand(parent common.Registerer, globals *config.Data) *ListCommand { - var c ListCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("list", "List HTTPS endpoints on a Fastly service version") - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Input.ServiceVersion) - return &c -} - -// Exec invokes the application logic for the command. -func (c *ListCommand) Exec(in io.Reader, out io.Writer) error { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - c.Input.ServiceID = serviceID - - httpss, err := c.Globals.Client.ListHTTPS(&c.Input) - if err != nil { - return err - } - - if !c.Globals.Verbose() { - tw := text.NewTable(out) - tw.AddHeader("SERVICE", "VERSION", "NAME") - for _, https := range httpss { - tw.AddLine(https.ServiceID, https.ServiceVersion, https.Name) - } - tw.Print() - return nil - } - - fmt.Fprintf(out, "Service ID: %s\n", c.Input.ServiceID) - fmt.Fprintf(out, "Version: %d\n", c.Input.ServiceVersion) - for i, https := range httpss { - fmt.Fprintf(out, "\tHTTPS %d/%d\n", i+1, len(httpss)) - fmt.Fprintf(out, "\t\tService ID: %s\n", https.ServiceID) - fmt.Fprintf(out, "\t\tVersion: %d\n", https.ServiceVersion) - fmt.Fprintf(out, "\t\tName: %s\n", https.Name) - fmt.Fprintf(out, "\t\tURL: %s\n", https.URL) - fmt.Fprintf(out, "\t\tContent type: %s\n", https.ContentType) - fmt.Fprintf(out, "\t\tHeader name: %s\n", https.HeaderName) - fmt.Fprintf(out, "\t\tHeader value: %s\n", https.HeaderValue) - fmt.Fprintf(out, "\t\tMethod: %s\n", https.Method) - fmt.Fprintf(out, "\t\tJSON format: %s\n", https.JSONFormat) - fmt.Fprintf(out, "\t\tTLS CA certificate: %s\n", https.TLSCACert) - fmt.Fprintf(out, "\t\tTLS client certificate: %s\n", https.TLSClientCert) - fmt.Fprintf(out, "\t\tTLS client key: %s\n", https.TLSClientKey) - fmt.Fprintf(out, "\t\tTLS hostname: %s\n", https.TLSHostname) - fmt.Fprintf(out, "\t\tRequest max entries: %d\n", https.RequestMaxEntries) - fmt.Fprintf(out, "\t\tRequest max bytes: %d\n", https.RequestMaxBytes) - fmt.Fprintf(out, "\t\tMessage type: %s\n", https.MessageType) - fmt.Fprintf(out, "\t\tFormat: %s\n", https.Format) - fmt.Fprintf(out, "\t\tFormat version: %d\n", https.FormatVersion) - fmt.Fprintf(out, "\t\tResponse condition: %s\n", https.ResponseCondition) - fmt.Fprintf(out, "\t\tPlacement: %s\n", https.Placement) - } - fmt.Fprintln(out) - - return nil -} diff --git a/pkg/logging/https/root.go b/pkg/logging/https/root.go deleted file mode 100644 index 28bd1bb5f..000000000 --- a/pkg/logging/https/root.go +++ /dev/null @@ -1,28 +0,0 @@ -package https - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/config" -) - -// RootCommand is the parent command for all subcommands in this package. -// It should be installed under the primary root command. -type RootCommand struct { - common.Base - // no flags -} - -// NewRootCommand returns a new command registered in the parent. -func NewRootCommand(parent common.Registerer, globals *config.Data) *RootCommand { - var c RootCommand - c.Globals = globals - c.CmdClause = parent.Command("https", "Manipulate Fastly service version HTTPS logging endpoints") - return &c -} - -// Exec implements the command interface. -func (c *RootCommand) Exec(in io.Reader, out io.Writer) error { - panic("unreachable") -} diff --git a/pkg/logging/https/update.go b/pkg/logging/https/update.go deleted file mode 100644 index dfaeb00ab..000000000 --- a/pkg/logging/https/update.go +++ /dev/null @@ -1,181 +0,0 @@ -package https - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// UpdateCommand calls the Fastly API to update an HTTPS logging endpoint. -type UpdateCommand struct { - common.Base - manifest manifest.Data - - // required - EndpointName string // Can't shadow common.Base method Name(). - Version int - - // optional - NewName common.OptionalString - URL common.OptionalString - RequestMaxEntries common.OptionalUint - RequestMaxBytes common.OptionalUint - TLSCACert common.OptionalString - TLSClientCert common.OptionalString - TLSClientKey common.OptionalString - TLSHostname common.OptionalString - MessageType common.OptionalString - ContentType common.OptionalString - HeaderName common.OptionalString - HeaderValue common.OptionalString - Method common.OptionalString - JSONFormat common.OptionalString - Format common.OptionalString - FormatVersion common.OptionalUint - Placement common.OptionalString - ResponseCondition common.OptionalString -} - -// NewUpdateCommand returns a usable command registered under the parent. -func NewUpdateCommand(parent common.Registerer, globals *config.Data) *UpdateCommand { - var c UpdateCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - - c.CmdClause = parent.Command("update", "Update an HTTPS logging endpoint on a Fastly service version") - - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Version) - c.CmdClause.Flag("name", "The name of the HTTPS logging object").Short('n').Required().StringVar(&c.EndpointName) - - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("new-name", "New name of the HTTPS logging object").Action(c.NewName.Set).StringVar(&c.NewName.Value) - c.CmdClause.Flag("url", "URL that log data will be sent to. Must use the https protocol").Action(c.URL.Set).StringVar(&c.URL.Value) - c.CmdClause.Flag("content-type", "Content type of the header sent with the request").Action(c.ContentType.Set).StringVar(&c.ContentType.Value) - c.CmdClause.Flag("header-name", "Name of the custom header sent with the request").Action(c.HeaderName.Set).StringVar(&c.HeaderName.Value) - c.CmdClause.Flag("header-value", "Value of the custom header sent with the request").Action(c.HeaderValue.Set).StringVar(&c.HeaderValue.Value) - c.CmdClause.Flag("method", "HTTP method used for request. Can be POST or PUT. Defaults to POST if not specified").Action(c.Method.Set).StringVar(&c.Method.Value) - c.CmdClause.Flag("json-format", "Enforces valid JSON formatting for log entries. Can be disabled 0, array of json (wraps JSON log batches in an array) 1, or newline delimited json (places each JSON log entry onto a new line in a batch) 2").Action(c.JSONFormat.Set).StringVar(&c.JSONFormat.Value) - c.CmdClause.Flag("tls-ca-cert", "A secure certificate to authenticate the server with. Must be in PEM format").Action(c.TLSCACert.Set).StringVar(&c.TLSCACert.Value) - c.CmdClause.Flag("tls-client-cert", "The client certificate used to make authenticated requests. Must be in PEM format").Action(c.TLSClientCert.Set).StringVar(&c.TLSClientCert.Value) - c.CmdClause.Flag("tls-client-key", "The client private key used to make authenticated requests. Must be in PEM format").Action(c.TLSClientKey.Set).StringVar(&c.TLSClientKey.Value) - c.CmdClause.Flag("tls-hostname", "The hostname used to verify the server's certificate. It can either be the Common Name or a Subject Alternative Name (SAN)").Action(c.TLSHostname.Set).StringVar(&c.TLSHostname.Value) - c.CmdClause.Flag("message-type", "How the message should be formatted. One of: classic (default), loggly, logplex or blank").Action(c.MessageType.Set).StringVar(&c.MessageType.Value) - c.CmdClause.Flag("format", "Apache style log formatting. Your log must produce valid JSON that HTTPS can ingest").Action(c.Format.Set).StringVar(&c.Format.Value) - c.CmdClause.Flag("format-version", "The version of the custom logging format used for the configured endpoint. Can be either 2 (default) or 1").Action(c.FormatVersion.Set).UintVar(&c.FormatVersion.Value) - c.CmdClause.Flag("placement", "Where in the generated VCL the logging call should be placed, overriding any format_version default. Can be none or waf_debug").Action(c.Placement.Set).StringVar(&c.Placement.Value) - c.CmdClause.Flag("response-condition", "The name of an existing condition in the configured endpoint, or leave blank to always execute").Action(c.ResponseCondition.Set).StringVar(&c.ResponseCondition.Value) - c.CmdClause.Flag("request-max-entries", "Maximum number of logs to append to a batch, if non-zero. Defaults to 0 for unbounded").Action(c.RequestMaxEntries.Set).UintVar(&c.RequestMaxEntries.Value) - c.CmdClause.Flag("request-max-bytes", "Maximum size of log batch, if non-zero. Defaults to 0 for unbounded").Action(c.RequestMaxBytes.Set).UintVar(&c.RequestMaxBytes.Value) - - return &c -} - -// createInput transforms values parsed from CLI flags into an object to be used by the API client library. -func (c *UpdateCommand) createInput() (*fastly.UpdateHTTPSInput, error) { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return nil, errors.ErrNoServiceID - } - - input := fastly.UpdateHTTPSInput{ - ServiceID: serviceID, - ServiceVersion: c.Version, - Name: c.EndpointName, - } - - if c.NewName.WasSet { - input.NewName = fastly.String(c.NewName.Value) - } - - if c.URL.WasSet { - input.URL = fastly.String(c.URL.Value) - } - - if c.ContentType.WasSet { - input.ContentType = fastly.String(c.ContentType.Value) - } - - if c.JSONFormat.WasSet { - input.JSONFormat = fastly.String(c.JSONFormat.Value) - } - - if c.HeaderName.WasSet { - input.HeaderName = fastly.String(c.HeaderName.Value) - } - - if c.HeaderValue.WasSet { - input.HeaderValue = fastly.String(c.HeaderValue.Value) - } - - if c.Method.WasSet { - input.Method = fastly.String(c.Method.Value) - } - - if c.RequestMaxEntries.WasSet { - input.RequestMaxEntries = fastly.Uint(c.RequestMaxEntries.Value) - } - - if c.RequestMaxBytes.WasSet { - input.RequestMaxBytes = fastly.Uint(c.RequestMaxBytes.Value) - } - - if c.TLSCACert.WasSet { - input.TLSCACert = fastly.String(c.TLSCACert.Value) - } - - if c.TLSClientCert.WasSet { - input.TLSClientCert = fastly.String(c.TLSClientCert.Value) - } - - if c.TLSClientKey.WasSet { - input.TLSClientKey = fastly.String(c.TLSClientKey.Value) - } - - if c.TLSHostname.WasSet { - input.TLSHostname = fastly.String(c.TLSHostname.Value) - } - - if c.Format.WasSet { - input.Format = fastly.String(c.Format.Value) - } - - if c.FormatVersion.WasSet { - input.FormatVersion = fastly.Uint(c.FormatVersion.Value) - } - - if c.ResponseCondition.WasSet { - input.ResponseCondition = fastly.String(c.ResponseCondition.Value) - } - - if c.Placement.WasSet { - input.Placement = fastly.String(c.Placement.Value) - } - - if c.MessageType.WasSet { - input.MessageType = fastly.String(c.MessageType.Value) - } - - return &input, nil -} - -// Exec invokes the application logic for the command. -func (c *UpdateCommand) Exec(in io.Reader, out io.Writer) error { - input, err := c.createInput() - if err != nil { - return err - } - - https, err := c.Globals.Client.UpdateHTTPS(input) - if err != nil { - return err - } - - text.Success(out, "Updated HTTPS logging endpoint %s (service %s version %d)", https.Name, https.ServiceID, https.ServiceVersion) - return nil -} diff --git a/pkg/logging/kafka/create.go b/pkg/logging/kafka/create.go deleted file mode 100644 index 619ee77f8..000000000 --- a/pkg/logging/kafka/create.go +++ /dev/null @@ -1,184 +0,0 @@ -package kafka - -import ( - "fmt" - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// CreateCommand calls the Fastly API to create a Kafka logging endpoint. -type CreateCommand struct { - common.Base - manifest manifest.Data - - // required - EndpointName string // Can't shadow common.Base method Name(). - Version int - Topic string - Brokers string - - // optional - UseTLS common.OptionalBool - CompressionCodec common.OptionalString - RequiredACKs common.OptionalString - TLSCACert common.OptionalString - TLSClientCert common.OptionalString - TLSClientKey common.OptionalString - TLSHostname common.OptionalString - Format common.OptionalString - FormatVersion common.OptionalUint - Placement common.OptionalString - ResponseCondition common.OptionalString - ParseLogKeyvals common.OptionalBool - RequestMaxBytes common.OptionalUint - UseSASL common.OptionalBool - AuthMethod common.OptionalString - User common.OptionalString - Password common.OptionalString -} - -// NewCreateCommand returns a usable command registered under the parent. -func NewCreateCommand(parent common.Registerer, globals *config.Data) *CreateCommand { - var c CreateCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("create", "Create a Kafka logging endpoint on a Fastly service version").Alias("add") - - c.CmdClause.Flag("name", "The name of the Kafka logging object. Used as a primary key for API access").Short('n').Required().StringVar(&c.EndpointName) - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Version) - c.CmdClause.Flag("topic", "The Kafka topic to send logs to").Required().StringVar(&c.Topic) - c.CmdClause.Flag("brokers", "A comma-separated list of IP addresses or hostnames of Kafka brokers").Required().StringVar(&c.Brokers) - - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("compression-codec", "The codec used for compression of your logs. One of: gzip, snappy, lz4").Action(c.CompressionCodec.Set).StringVar(&c.CompressionCodec.Value) - c.CmdClause.Flag("required-acks", "The Number of acknowledgements a leader must receive before a write is considered successful. One of: 1 (default) One server needs to respond. 0 No servers need to respond. -1 Wait for all in-sync replicas to respond").Action(c.RequiredACKs.Set).StringVar(&c.RequiredACKs.Value) - c.CmdClause.Flag("use-tls", "Whether to use TLS for secure logging. Can be either true or false").Action(c.UseTLS.Set).BoolVar(&c.UseTLS.Value) - c.CmdClause.Flag("tls-ca-cert", "A secure certificate to authenticate the server with. Must be in PEM format").Action(c.TLSCACert.Set).StringVar(&c.TLSCACert.Value) - c.CmdClause.Flag("tls-client-cert", "The client certificate used to make authenticated requests. Must be in PEM format").Action(c.TLSClientCert.Set).StringVar(&c.TLSClientCert.Value) - c.CmdClause.Flag("tls-client-key", "The client private key used to make authenticated requests. Must be in PEM format").Action(c.TLSClientKey.Set).StringVar(&c.TLSClientKey.Value) - c.CmdClause.Flag("tls-hostname", "The hostname used to verify the server's certificate. It can either be the Common Name or a Subject Alternative Name (SAN)").Action(c.TLSHostname.Set).StringVar(&c.TLSHostname.Value) - c.CmdClause.Flag("format", "Apache style log formatting. Your log must produce valid JSON that Kafka can ingest").Action(c.Format.Set).StringVar(&c.Format.Value) - c.CmdClause.Flag("format-version", "The version of the custom logging format used for the configured endpoint. Can be either 2 (default) or 1").Action(c.FormatVersion.Set).UintVar(&c.FormatVersion.Value) - c.CmdClause.Flag("placement", "Where in the generated VCL the logging call should be placed, overriding any format_version default. Can be none or waf_debug").Action(c.Placement.Set).StringVar(&c.Placement.Value) - c.CmdClause.Flag("response-condition", "The name of an existing condition in the configured endpoint, or leave blank to always execute").Action(c.ResponseCondition.Set).StringVar(&c.ResponseCondition.Value) - c.CmdClause.Flag("parse-log-keyvals", "Parse key-value pairs within the log format").Action(c.ParseLogKeyvals.Set).BoolVar(&c.ParseLogKeyvals.Value) - c.CmdClause.Flag("max-batch-size", "The maximum size of the log batch in bytes").Action(c.RequestMaxBytes.Set).UintVar(&c.RequestMaxBytes.Value) - c.CmdClause.Flag("use-sasl", "Enable SASL authentication. Requires --auth-method, --username, and --password to be specified").Action(c.UseSASL.Set).BoolVar(&c.UseSASL.Value) - c.CmdClause.Flag("auth-method", "SASL authentication method. Valid values are: plain, scram-sha-256, scram-sha-512").Action(c.AuthMethod.Set).HintOptions("plain", "scram-sha-256", "scram-sha-512").EnumVar(&c.AuthMethod.Value, "plain", "scram-sha-256", "scram-sha-512") - c.CmdClause.Flag("username", "SASL authentication username. Required if --auth-method is specified").Action(c.User.Set).StringVar(&c.User.Value) - c.CmdClause.Flag("password", "SASL authentication password. Required if --auth-method is specified").Action(c.Password.Set).StringVar(&c.Password.Value) - - return &c -} - -// createInput transforms values parsed from CLI flags into an object to be used by the API client library. -func (c *CreateCommand) createInput() (*fastly.CreateKafkaInput, error) { - var input fastly.CreateKafkaInput - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return nil, errors.ErrNoServiceID - } - - if c.UseSASL.WasSet && c.UseSASL.Value && (c.AuthMethod.Value == "" || c.User.Value == "" || c.Password.Value == "") { - return nil, fmt.Errorf("the --auth-method, --username, and --password flags must be present when using the --use-sasl flag") - } - - if !c.UseSASL.Value && (c.AuthMethod.Value != "" || c.User.Value != "" || c.Password.Value != "") { - return nil, fmt.Errorf("the --auth-method, --username, and --password options are only valid when the --use-sasl flag is specified") - } - - input.ServiceID = serviceID - input.ServiceVersion = c.Version - input.Name = c.EndpointName - input.Topic = c.Topic - input.Brokers = c.Brokers - - if c.CompressionCodec.WasSet { - input.CompressionCodec = c.CompressionCodec.Value - } - - if c.RequiredACKs.WasSet { - input.RequiredACKs = c.RequiredACKs.Value - } - - if c.UseTLS.WasSet { - input.UseTLS = fastly.Compatibool(c.UseTLS.Value) - } - - if c.TLSCACert.WasSet { - input.TLSCACert = c.TLSCACert.Value - } - - if c.TLSClientCert.WasSet { - input.TLSClientCert = c.TLSClientCert.Value - } - - if c.TLSClientKey.WasSet { - input.TLSClientKey = c.TLSClientKey.Value - } - - if c.TLSHostname.WasSet { - input.TLSHostname = c.TLSHostname.Value - } - - if c.Format.WasSet { - input.Format = c.Format.Value - } - - if c.FormatVersion.WasSet { - input.FormatVersion = c.FormatVersion.Value - } - - if c.ResponseCondition.WasSet { - input.ResponseCondition = c.ResponseCondition.Value - } - - if c.Placement.WasSet { - input.Placement = c.Placement.Value - } - - if c.ParseLogKeyvals.WasSet { - input.ParseLogKeyvals = fastly.Compatibool(c.ParseLogKeyvals.Value) - } - - if c.RequestMaxBytes.WasSet { - input.RequestMaxBytes = c.RequestMaxBytes.Value - } - - if c.AuthMethod.WasSet { - input.AuthMethod = c.AuthMethod.Value - } - - if c.User.WasSet { - input.User = c.User.Value - } - - if c.Password.WasSet { - input.Password = c.Password.Value - } - - return &input, nil -} - -// Exec invokes the application logic for the command. -func (c *CreateCommand) Exec(in io.Reader, out io.Writer) error { - input, err := c.createInput() - if err != nil { - return err - } - - d, err := c.Globals.Client.CreateKafka(input) - if err != nil { - return err - } - - text.Success(out, "Created Kafka logging endpoint %s (service %s version %d)", d.Name, d.ServiceID, d.ServiceVersion) - return nil -} diff --git a/pkg/logging/kafka/delete.go b/pkg/logging/kafka/delete.go deleted file mode 100644 index 4a244f1f5..000000000 --- a/pkg/logging/kafka/delete.go +++ /dev/null @@ -1,51 +0,0 @@ -package kafka - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// DeleteCommand calls the Fastly API to delete a Kafka logging endpoint. -type DeleteCommand struct { - common.Base - manifest manifest.Data - Input fastly.DeleteKafkaInput -} - -// NewDeleteCommand returns a usable command registered under the parent. -func NewDeleteCommand(parent common.Registerer, globals *config.Data) *DeleteCommand { - var c DeleteCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("delete", "Delete a Kafka logging endpoint on a Fastly service version").Alias("remove") - - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Input.ServiceVersion) - c.CmdClause.Flag("name", "The name of the Kafka logging object").Short('n').Required().StringVar(&c.Input.Name) - - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - - return &c -} - -// Exec invokes the application logic for the command. -func (c *DeleteCommand) Exec(in io.Reader, out io.Writer) error { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - c.Input.ServiceID = serviceID - - if err := c.Globals.Client.DeleteKafka(&c.Input); err != nil { - return err - } - - text.Success(out, "Deleted Kafka logging endpoint %s (service %s version %d)", c.Input.Name, c.Input.ServiceID, c.Input.ServiceVersion) - return nil -} diff --git a/pkg/logging/kafka/describe.go b/pkg/logging/kafka/describe.go deleted file mode 100644 index 4b43043fa..000000000 --- a/pkg/logging/kafka/describe.go +++ /dev/null @@ -1,70 +0,0 @@ -package kafka - -import ( - "fmt" - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/go-fastly/v3/fastly" -) - -// DescribeCommand calls the Fastly API to describe a Kafka logging endpoint. -type DescribeCommand struct { - common.Base - manifest manifest.Data - Input fastly.GetKafkaInput -} - -// NewDescribeCommand returns a usable command registered under the parent. -func NewDescribeCommand(parent common.Registerer, globals *config.Data) *DescribeCommand { - var c DescribeCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("describe", "Show detailed information about a Kafka logging endpoint on a Fastly service version").Alias("get") - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Input.ServiceVersion) - c.CmdClause.Flag("name", "The name of the Kafka logging object").Short('n').Required().StringVar(&c.Input.Name) - return &c -} - -// Exec invokes the application logic for the command. -func (c *DescribeCommand) Exec(in io.Reader, out io.Writer) error { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - c.Input.ServiceID = serviceID - - kafka, err := c.Globals.Client.GetKafka(&c.Input) - if err != nil { - return err - } - - fmt.Fprintf(out, "Service ID: %s\n", kafka.ServiceID) - fmt.Fprintf(out, "Version: %d\n", kafka.ServiceVersion) - fmt.Fprintf(out, "Name: %s\n", kafka.Name) - fmt.Fprintf(out, "Topic: %s\n", kafka.Topic) - fmt.Fprintf(out, "Brokers: %s\n", kafka.Brokers) - fmt.Fprintf(out, "Required acks: %s\n", kafka.RequiredACKs) - fmt.Fprintf(out, "Compression codec: %s\n", kafka.CompressionCodec) - fmt.Fprintf(out, "Use TLS: %t\n", kafka.UseTLS) - fmt.Fprintf(out, "TLS CA certificate: %s\n", kafka.TLSCACert) - fmt.Fprintf(out, "TLS client certificate: %s\n", kafka.TLSClientCert) - fmt.Fprintf(out, "TLS client key: %s\n", kafka.TLSClientKey) - fmt.Fprintf(out, "TLS hostname: %s\n", kafka.TLSHostname) - fmt.Fprintf(out, "Format: %s\n", kafka.Format) - fmt.Fprintf(out, "Format version: %d\n", kafka.FormatVersion) - fmt.Fprintf(out, "Response condition: %s\n", kafka.ResponseCondition) - fmt.Fprintf(out, "Placement: %s\n", kafka.Placement) - fmt.Fprintf(out, "Parse log key-values: %t\n", kafka.ParseLogKeyvals) - fmt.Fprintf(out, "Max batch size: %d\n", kafka.RequestMaxBytes) - fmt.Fprintf(out, "SASL authentication method: %s\n", kafka.AuthMethod) - fmt.Fprintf(out, "SASL authentication username: %s\n", kafka.User) - fmt.Fprintf(out, "SASL authentication password: %s\n", kafka.Password) - - return nil -} diff --git a/pkg/logging/kafka/kafka_integration_test.go b/pkg/logging/kafka/kafka_integration_test.go deleted file mode 100644 index 94a5ac5d9..000000000 --- a/pkg/logging/kafka/kafka_integration_test.go +++ /dev/null @@ -1,499 +0,0 @@ -package kafka_test - -import ( - "bytes" - "errors" - "io" - "net/http" - "strings" - "testing" - - "github.com/fastly/cli/pkg/app" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/mock" - "github.com/fastly/cli/pkg/testutil" - "github.com/fastly/cli/pkg/update" - "github.com/fastly/go-fastly/v3/fastly" -) - -func TestKafkaCreate(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "kafka", "create", "--service-id", "123", "--version", "1", "--name", "log", "--brokers", "127.0.0.1,127.0.0.2"}, - wantError: "error parsing arguments: required flag --topic not provided", - }, - { - args: []string{"logging", "kafka", "create", "--service-id", "123", "--version", "1", "--name", "log", "--topic", "logs"}, - wantError: "error parsing arguments: required flag --brokers not provided", - }, - { - args: []string{"logging", "kafka", "create", "--service-id", "123", "--version", "1", "--name", "log", "--topic", "logs", "--brokers", "127.0.0.1,127.0.0.2", "--parse-log-keyvals", "--max-batch-size", "1024", "--use-sasl", "--auth-method", "plain", "--username", "user", "--password", "password"}, - api: mock.API{CreateKafkaFn: createKafkaOK}, - wantOutput: "Created Kafka logging endpoint log (service 123 version 1)", - }, - { - args: []string{"logging", "kafka", "create", "--service-id", "123", "--version", "1", "--name", "log", "--topic", "logs", "--brokers", "127.0.0.1,127.0.0.2"}, - api: mock.API{CreateKafkaFn: createKafkaError}, - wantError: errTest.Error(), - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertStringContains(t, out.String(), testcase.wantOutput) - }) - } -} - -func TestKafkaList(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "kafka", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListKafkasFn: listKafkasOK}, - wantOutput: listKafkasShortOutput, - }, - { - args: []string{"logging", "kafka", "list", "--service-id", "123", "--version", "1", "--verbose"}, - api: mock.API{ListKafkasFn: listKafkasOK}, - wantOutput: listKafkasVerboseOutput, - }, - { - args: []string{"logging", "kafka", "list", "--service-id", "123", "--version", "1", "-v"}, - api: mock.API{ListKafkasFn: listKafkasOK}, - wantOutput: listKafkasVerboseOutput, - }, - { - args: []string{"logging", "kafka", "--verbose", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListKafkasFn: listKafkasOK}, - wantOutput: listKafkasVerboseOutput, - }, - { - args: []string{"logging", "-v", "kafka", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListKafkasFn: listKafkasOK}, - wantOutput: listKafkasVerboseOutput, - }, - { - args: []string{"logging", "kafka", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListKafkasFn: listKafkasError}, - wantError: errTest.Error(), - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertString(t, testcase.wantOutput, out.String()) - }) - } -} - -func TestKafkaDescribe(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "kafka", "describe", "--service-id", "123", "--version", "1"}, - wantError: "error parsing arguments: required flag --name not provided", - }, - { - args: []string{"logging", "kafka", "describe", "--service-id", "123", "--version", "1", "--name", "logs"}, - api: mock.API{GetKafkaFn: getKafkaError}, - wantError: errTest.Error(), - }, - { - args: []string{"logging", "kafka", "describe", "--service-id", "123", "--version", "1", "--name", "logs"}, - api: mock.API{GetKafkaFn: getKafkaOK}, - wantOutput: describeKafkaOutput, - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertString(t, testcase.wantOutput, out.String()) - }) - } -} - -func TestKafkaUpdate(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "kafka", "update", "--service-id", "123", "--version", "1", "--new-name", "log"}, - wantError: "error parsing arguments: required flag --name not provided", - }, - { - args: []string{"logging", "kafka", "update", "--service-id", "123", "--version", "1", "--name", "logs", "--new-name", "log"}, - api: mock.API{UpdateKafkaFn: updateKafkaError}, - wantError: errTest.Error(), - }, - { - args: []string{"logging", "kafka", "update", "--service-id", "123", "--version", "1", "--name", "logs", "--new-name", "log"}, - api: mock.API{UpdateKafkaFn: updateKafkaOK}, - wantOutput: "Updated Kafka logging endpoint log (service 123 version 1)", - }, - { - args: []string{"logging", "kafka", "update", "--service-id", "123", "--version", "1", "--name", "logs", "--new-name", "log", "--parse-log-keyvals", "--max-batch-size", "1024", "--use-sasl", "--auth-method", "plain", "--username", "user", "--password", "password"}, - api: mock.API{UpdateKafkaFn: updateKafkaSASL}, - wantOutput: "Updated Kafka logging endpoint log (service 123 version 1)", - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertStringContains(t, out.String(), testcase.wantOutput) - }) - } -} - -func TestKafkaDelete(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "kafka", "delete", "--service-id", "123", "--version", "1"}, - wantError: "error parsing arguments: required flag --name not provided", - }, - { - args: []string{"logging", "kafka", "delete", "--service-id", "123", "--version", "1", "--name", "logs"}, - api: mock.API{DeleteKafkaFn: deleteKafkaError}, - wantError: errTest.Error(), - }, - { - args: []string{"logging", "kafka", "delete", "--service-id", "123", "--version", "1", "--name", "logs"}, - api: mock.API{DeleteKafkaFn: deleteKafkaOK}, - wantOutput: "Deleted Kafka logging endpoint logs (service 123 version 1)", - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertStringContains(t, out.String(), testcase.wantOutput) - }) - } -} - -var errTest = errors.New("fixture error") - -func createKafkaOK(i *fastly.CreateKafkaInput) (*fastly.Kafka, error) { - return &fastly.Kafka{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "log", - ResponseCondition: "Prevent default logging", - Format: `%h %l %u %t "%r" %>s %b`, - Topic: "logs", - Brokers: "127.0.0.1,127.0.0.2", - RequiredACKs: "-1", - CompressionCodec: "zippy", - UseTLS: true, - Placement: "none", - TLSCACert: "-----BEGIN CERTIFICATE-----foo", - TLSHostname: "127.0.0.1,127.0.0.2", - TLSClientCert: "-----BEGIN CERTIFICATE-----bar", - TLSClientKey: "-----BEGIN PRIVATE KEY-----bar", - FormatVersion: 2, - ParseLogKeyvals: true, - RequestMaxBytes: 1024, - AuthMethod: "plain", - User: "user", - Password: "password", - }, nil -} - -func createKafkaError(i *fastly.CreateKafkaInput) (*fastly.Kafka, error) { - return nil, errTest -} - -func listKafkasOK(i *fastly.ListKafkasInput) ([]*fastly.Kafka, error) { - return []*fastly.Kafka{ - { - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "logs", - ResponseCondition: "Prevent default logging", - Format: `%h %l %u %t "%r" %>s %b`, - Topic: "logs", - Brokers: "127.0.0.1,127.0.0.2", - RequiredACKs: "-1", - CompressionCodec: "zippy", - UseTLS: true, - Placement: "none", - TLSCACert: "-----BEGIN CERTIFICATE-----foo", - TLSHostname: "127.0.0.1,127.0.0.2", - TLSClientCert: "-----BEGIN CERTIFICATE-----bar", - TLSClientKey: "-----BEGIN PRIVATE KEY-----bar", - FormatVersion: 2, - ParseLogKeyvals: false, - RequestMaxBytes: 0, - AuthMethod: "", - User: "", - Password: "", - }, - { - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "analytics", - Topic: "analytics", - Brokers: "127.0.0.1,127.0.0.2", - RequiredACKs: "-1", - CompressionCodec: "zippy", - UseTLS: true, - Placement: "none", - TLSCACert: "-----BEGIN CERTIFICATE-----foo", - TLSHostname: "127.0.0.1,127.0.0.2", - TLSClientCert: "-----BEGIN CERTIFICATE-----bar", - TLSClientKey: "-----BEGIN PRIVATE KEY-----bar", - ResponseCondition: "Prevent default logging", - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - ParseLogKeyvals: false, - RequestMaxBytes: 0, - AuthMethod: "", - User: "", - Password: "", - }, - }, nil -} - -func listKafkasError(i *fastly.ListKafkasInput) ([]*fastly.Kafka, error) { - return nil, errTest -} - -var listKafkasShortOutput = strings.TrimSpace(` -SERVICE VERSION NAME -123 1 logs -123 1 analytics -`) + "\n" - -var listKafkasVerboseOutput = strings.TrimSpace(` -Fastly API token not provided -Fastly API endpoint: https://api.fastly.com -Service ID: 123 -Version: 1 - Kafka 1/2 - Service ID: 123 - Version: 1 - Name: logs - Topic: logs - Brokers: 127.0.0.1,127.0.0.2 - Required acks: -1 - Compression codec: zippy - Use TLS: true - TLS CA certificate: -----BEGIN CERTIFICATE-----foo - TLS client certificate: -----BEGIN CERTIFICATE-----bar - TLS client key: -----BEGIN PRIVATE KEY-----bar - TLS hostname: 127.0.0.1,127.0.0.2 - Format: %h %l %u %t "%r" %>s %b - Format version: 2 - Response condition: Prevent default logging - Placement: none - Parse log key-values: false - Max batch size: 0 - SASL authentication method: - SASL authentication username: - SASL authentication password: - Kafka 2/2 - Service ID: 123 - Version: 1 - Name: analytics - Topic: analytics - Brokers: 127.0.0.1,127.0.0.2 - Required acks: -1 - Compression codec: zippy - Use TLS: true - TLS CA certificate: -----BEGIN CERTIFICATE-----foo - TLS client certificate: -----BEGIN CERTIFICATE-----bar - TLS client key: -----BEGIN PRIVATE KEY-----bar - TLS hostname: 127.0.0.1,127.0.0.2 - Format: %h %l %u %t "%r" %>s %b - Format version: 2 - Response condition: Prevent default logging - Placement: none - Parse log key-values: false - Max batch size: 0 - SASL authentication method: - SASL authentication username: - SASL authentication password: -`) + " \n\n" - -func getKafkaOK(i *fastly.GetKafkaInput) (*fastly.Kafka, error) { - return &fastly.Kafka{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "log", - Brokers: "127.0.0.1,127.0.0.2", - Topic: "logs", - RequiredACKs: "-1", - UseTLS: true, - CompressionCodec: "zippy", - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - ResponseCondition: "Prevent default logging", - Placement: "none", - TLSCACert: "-----BEGIN CERTIFICATE-----foo", - TLSHostname: "127.0.0.1,127.0.0.2", - TLSClientCert: "-----BEGIN CERTIFICATE-----bar", - TLSClientKey: "-----BEGIN PRIVATE KEY-----bar", - }, nil -} - -func getKafkaError(i *fastly.GetKafkaInput) (*fastly.Kafka, error) { - return nil, errTest -} - -var describeKafkaOutput = strings.TrimSpace(` -Service ID: 123 -Version: 1 -Name: log -Topic: logs -Brokers: 127.0.0.1,127.0.0.2 -Required acks: -1 -Compression codec: zippy -Use TLS: true -TLS CA certificate: -----BEGIN CERTIFICATE-----foo -TLS client certificate: -----BEGIN CERTIFICATE-----bar -TLS client key: -----BEGIN PRIVATE KEY-----bar -TLS hostname: 127.0.0.1,127.0.0.2 -Format: %h %l %u %t "%r" %>s %b -Format version: 2 -Response condition: Prevent default logging -Placement: none -Parse log key-values: false -Max batch size: 0 -SASL authentication method: -SASL authentication username: -SASL authentication password: -`) + " \n" - -func updateKafkaOK(i *fastly.UpdateKafkaInput) (*fastly.Kafka, error) { - return &fastly.Kafka{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "log", - ResponseCondition: "Prevent default logging", - Format: `%h %l %u %t "%r" %>s %b`, - Topic: "logs", - Brokers: "127.0.0.1,127.0.0.2", - RequiredACKs: "-1", - CompressionCodec: "zippy", - UseTLS: true, - Placement: "none", - TLSCACert: "-----BEGIN CERTIFICATE-----foo", - TLSHostname: "127.0.0.1,127.0.0.2", - TLSClientCert: "-----BEGIN CERTIFICATE-----bar", - TLSClientKey: "-----BEGIN PRIVATE KEY-----bar", - FormatVersion: 2, - }, nil -} - -func updateKafkaSASL(i *fastly.UpdateKafkaInput) (*fastly.Kafka, error) { - return &fastly.Kafka{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "log", - ResponseCondition: "Prevent default logging", - Format: `%h %l %u %t "%r" %>s %b`, - Topic: "logs", - Brokers: "127.0.0.1,127.0.0.2", - RequiredACKs: "-1", - CompressionCodec: "zippy", - UseTLS: true, - Placement: "none", - TLSCACert: "-----BEGIN CERTIFICATE-----foo", - TLSHostname: "127.0.0.1,127.0.0.2", - TLSClientCert: "-----BEGIN CERTIFICATE-----bar", - TLSClientKey: "-----BEGIN PRIVATE KEY-----bar", - FormatVersion: 2, - ParseLogKeyvals: true, - RequestMaxBytes: 1024, - AuthMethod: "plain", - User: "user", - Password: "password", - }, nil -} - -func updateKafkaError(i *fastly.UpdateKafkaInput) (*fastly.Kafka, error) { - return nil, errTest -} - -func deleteKafkaOK(i *fastly.DeleteKafkaInput) error { - return nil -} - -func deleteKafkaError(i *fastly.DeleteKafkaInput) error { - return errTest -} diff --git a/pkg/logging/kafka/kafka_test.go b/pkg/logging/kafka/kafka_test.go deleted file mode 100644 index 2c77bfbcb..000000000 --- a/pkg/logging/kafka/kafka_test.go +++ /dev/null @@ -1,452 +0,0 @@ -package kafka - -import ( - "testing" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/mock" - "github.com/fastly/cli/pkg/testutil" - "github.com/fastly/go-fastly/v3/fastly" -) - -func TestCreateKafkaInput(t *testing.T) { - for _, testcase := range []struct { - name string - cmd *CreateCommand - want *fastly.CreateKafkaInput - wantError string - }{ - { - name: "required values set flag serviceID", - cmd: createCommandRequired(), - want: &fastly.CreateKafkaInput{ - ServiceID: "123", - ServiceVersion: 2, - Name: "log", - Topic: "logs", - Brokers: "127.0.0.1,127.0.0.2", - }, - }, - { - name: "all values set flag serviceID", - cmd: createCommandAll(), - want: &fastly.CreateKafkaInput{ - ServiceID: "123", - ServiceVersion: 2, - Name: "logs", - Brokers: "127.0.0.1,127.0.0.2", - Topic: "logs", - RequiredACKs: "-1", - UseTLS: true, - CompressionCodec: "zippy", - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - ResponseCondition: "Prevent default logging", - Placement: "none", - TLSCACert: "-----BEGIN CERTIFICATE-----foo", - TLSHostname: "example.com", - TLSClientCert: "-----BEGIN CERTIFICATE-----bar", - TLSClientKey: "-----BEGIN PRIVATE KEY-----bar", - }, - }, - { - name: "error missing serviceID", - cmd: createCommandMissingServiceID(), - want: nil, - wantError: errors.ErrNoServiceID.Error(), - }, - { - name: "verify SASL fields", - cmd: createCommandSASL("scram-sha-512", "user1", "12345"), - want: &fastly.CreateKafkaInput{ - ServiceID: "123", - ServiceVersion: 2, - Name: "log", - Topic: "logs", - Brokers: "127.0.0.1,127.0.0.2", - ParseLogKeyvals: true, - RequestMaxBytes: 11111, - AuthMethod: "scram-sha-512", - User: "user1", - Password: "12345", - }, - }, - { - name: "verify SASL validation: missing username", - cmd: createCommandSASL("scram-sha-256", "", "password"), - want: nil, - wantError: "the --auth-method, --username, and --password flags must be present when using the --use-sasl flag", - }, - { - name: "verify SASL validation: missing password", - cmd: createCommandSASL("plain", "user", ""), - want: nil, - wantError: "the --auth-method, --username, and --password flags must be present when using the --use-sasl flag", - }, - { - name: "verify SASL validation: username with no auth method or password", - cmd: createCommandSASL("", "user1", ""), - want: nil, - wantError: "the --auth-method, --username, and --password flags must be present when using the --use-sasl flag", - }, - { - name: "verify SASL validation: password with no auth method", - cmd: createCommandSASL("", "", "password"), - want: nil, - wantError: "the --auth-method, --username, and --password flags must be present when using the --use-sasl flag", - }, - - { - name: "verify SASL validation: no SASL, but auth-method given", - cmd: createCommandNoSASL("scram-sha-256", "", ""), - want: nil, - wantError: "the --auth-method, --username, and --password options are only valid when the --use-sasl flag is specified", - }, - { - name: "verify SASL validation: no SASL, but username with given", - cmd: createCommandNoSASL("", "user1", ""), - want: nil, - wantError: "the --auth-method, --username, and --password options are only valid when the --use-sasl flag is specified", - }, - { - name: "verify SASL validation: no SASL, but password given", - cmd: createCommandNoSASL("", "", "password"), - want: nil, - wantError: "the --auth-method, --username, and --password options are only valid when the --use-sasl flag is specified", - }, - } { - t.Run(testcase.name, func(t *testing.T) { - have, err := testcase.cmd.createInput() - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertEqual(t, testcase.want, have) - }) - } -} - -func TestUpdateKafkaInput(t *testing.T) { - for _, testcase := range []struct { - name string - cmd *UpdateCommand - api mock.API - want *fastly.UpdateKafkaInput - wantError string - }{ - { - name: "all values set flag serviceID", - cmd: updateCommandAll(), - api: mock.API{GetKafkaFn: getKafkaOK}, - want: &fastly.UpdateKafkaInput{ - ServiceID: "123", - ServiceVersion: 2, - Name: "log", - NewName: fastly.String("new1"), - Topic: fastly.String("new2"), - Brokers: fastly.String("new3"), - RequiredACKs: fastly.String("new4"), - UseTLS: fastly.CBool(false), - CompressionCodec: fastly.String("new5"), - Placement: fastly.String("new6"), - Format: fastly.String("new7"), - FormatVersion: fastly.Uint(3), - ResponseCondition: fastly.String("new8"), - TLSCACert: fastly.String("new9"), - TLSClientCert: fastly.String("new10"), - TLSClientKey: fastly.String("new11"), - TLSHostname: fastly.String("new12"), - ParseLogKeyvals: fastly.CBool(false), - RequestMaxBytes: fastly.Uint(22222), - AuthMethod: fastly.String("plain"), - User: fastly.String("new13"), - Password: fastly.String("new14"), - }, - }, - { - name: "no updates", - cmd: updateCommandNoUpdates(), - api: mock.API{GetKafkaFn: getKafkaOK}, - want: &fastly.UpdateKafkaInput{ - ServiceID: "123", - ServiceVersion: 2, - Name: "log", - }, - }, - { - name: "error missing serviceID", - cmd: updateCommandMissingServiceID(), - want: nil, - wantError: errors.ErrNoServiceID.Error(), - }, - { - name: "verify SASL fields", - api: mock.API{GetKafkaFn: getKafkaOK}, - cmd: updateCommandSASL("scram-sha-512", "user1", "12345"), - want: &fastly.UpdateKafkaInput{ - ServiceID: "123", - ServiceVersion: 2, - Name: "log", - Topic: fastly.String("logs"), - Brokers: fastly.String("127.0.0.1,127.0.0.2"), - ParseLogKeyvals: fastly.CBool(true), - RequestMaxBytes: fastly.Uint(11111), - AuthMethod: fastly.String("scram-sha-512"), - User: fastly.String("user1"), - Password: fastly.String("12345"), - }, - }, - { - name: "verify disabling SASL", - api: mock.API{GetKafkaFn: getKafkaSASL}, - cmd: updateCommandNoSASL(), - want: &fastly.UpdateKafkaInput{ - ServiceID: "123", - ServiceVersion: 2, - Name: "log", - Topic: fastly.String("logs"), - Brokers: fastly.String("127.0.0.1,127.0.0.2"), - ParseLogKeyvals: fastly.CBool(true), - RequestMaxBytes: fastly.Uint(11111), - AuthMethod: fastly.String(""), - User: fastly.String(""), - Password: fastly.String(""), - }, - }, - { - name: "verify SASL validation: missing username", - api: mock.API{GetKafkaFn: getKafkaOK}, - cmd: updateCommandSASL("scram-sha-256", "", "password"), - want: nil, - wantError: "the --auth-method, --username, and --password flags must be present when using the --use-sasl flag", - }, - { - name: "verify SASL validation: missing password", - api: mock.API{GetKafkaFn: getKafkaOK}, - cmd: updateCommandSASL("plain", "user", ""), - want: nil, - wantError: "the --auth-method, --username, and --password flags must be present when using the --use-sasl flag", - }, - { - name: "verify SASL validation: username with no auth method", - api: mock.API{GetKafkaFn: getKafkaOK}, - cmd: updateCommandSASL("", "user1", ""), - want: nil, - wantError: "the --auth-method, --username, and --password flags must be present when using the --use-sasl flag", - }, - { - name: "verify SASL validation: password with no auth method", - api: mock.API{GetKafkaFn: getKafkaOK}, - cmd: updateCommandSASL("", "", "password"), - want: nil, - wantError: "the --auth-method, --username, and --password flags must be present when using the --use-sasl flag", - }, - } { - t.Run(testcase.name, func(t *testing.T) { - testcase.cmd.Base.Globals.Client = testcase.api - - have, err := testcase.cmd.createInput() - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertEqual(t, testcase.want, have) - }) - } -} - -func createCommandRequired() *CreateCommand { - return &CreateCommand{ - manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, - EndpointName: "log", - Version: 2, - Topic: "logs", - Brokers: "127.0.0.1,127.0.0.2", - } -} - -func createCommandAll() *CreateCommand { - return &CreateCommand{ - manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, - EndpointName: "logs", - Version: 2, - Topic: "logs", - Brokers: "127.0.0.1,127.0.0.2", - UseTLS: common.OptionalBool{Optional: common.Optional{WasSet: true}, Value: true}, - RequiredACKs: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "-1"}, - CompressionCodec: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "zippy"}, - Format: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: `%h %l %u %t "%r" %>s %b`}, - FormatVersion: common.OptionalUint{Optional: common.Optional{WasSet: true}, Value: 2}, - ResponseCondition: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "Prevent default logging"}, - Placement: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "none"}, - TLSCACert: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "-----BEGIN CERTIFICATE-----foo"}, - TLSHostname: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "example.com"}, - TLSClientCert: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "-----BEGIN CERTIFICATE-----bar"}, - TLSClientKey: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "-----BEGIN PRIVATE KEY-----bar"}, - } -} - -func createCommandSASL(authMethod, user, password string) *CreateCommand { - return &CreateCommand{ - manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, - EndpointName: "log", - Version: 2, - Topic: "logs", - Brokers: "127.0.0.1,127.0.0.2", - ParseLogKeyvals: common.OptionalBool{Optional: common.Optional{WasSet: true}, Value: true}, - RequestMaxBytes: common.OptionalUint{Optional: common.Optional{WasSet: true}, Value: 11111}, - UseSASL: common.OptionalBool{Optional: common.Optional{WasSet: true}, Value: true}, - AuthMethod: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: authMethod}, - User: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: user}, - Password: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: password}, - } -} - -func createCommandNoSASL(authMethod, user, password string) *CreateCommand { - return &CreateCommand{ - manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, - EndpointName: "log", - Version: 2, - Topic: "logs", - Brokers: "127.0.0.1,127.0.0.2", - ParseLogKeyvals: common.OptionalBool{Optional: common.Optional{WasSet: true}, Value: true}, - RequestMaxBytes: common.OptionalUint{Optional: common.Optional{WasSet: true}, Value: 11111}, - UseSASL: common.OptionalBool{Optional: common.Optional{WasSet: true}, Value: false}, - AuthMethod: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: authMethod}, - User: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: user}, - Password: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: password}, - } -} - -func createCommandMissingServiceID() *CreateCommand { - res := createCommandAll() - res.manifest = manifest.Data{} - return res -} - -func updateCommandNoUpdates() *UpdateCommand { - return &UpdateCommand{ - Base: common.Base{Globals: &config.Data{Client: nil}}, - manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, - EndpointName: "log", - Version: 2, - } -} - -func updateCommandAll() *UpdateCommand { - return &UpdateCommand{ - Base: common.Base{Globals: &config.Data{Client: nil}}, - manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, - EndpointName: "log", - Version: 2, - NewName: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new1"}, - Topic: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new2"}, - Brokers: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new3"}, - UseTLS: common.OptionalBool{Optional: common.Optional{WasSet: true}, Value: false}, - RequiredACKs: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new4"}, - CompressionCodec: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new5"}, - Placement: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new6"}, - Format: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new7"}, - FormatVersion: common.OptionalUint{Optional: common.Optional{WasSet: true}, Value: 3}, - ResponseCondition: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new8"}, - TLSCACert: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new9"}, - TLSClientCert: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new10"}, - TLSClientKey: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new11"}, - TLSHostname: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new12"}, - ParseLogKeyvals: common.OptionalBool{Optional: common.Optional{WasSet: true}, Value: false}, - RequestMaxBytes: common.OptionalUint{Optional: common.Optional{WasSet: true}, Value: 22222}, - UseSASL: common.OptionalBool{Optional: common.Optional{WasSet: true}, Value: true}, - AuthMethod: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "plain"}, - User: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new13"}, - Password: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new14"}, - } -} - -func updateCommandSASL(authMethod, user, password string) *UpdateCommand { - return &UpdateCommand{ - Base: common.Base{Globals: &config.Data{Client: nil}}, - manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, - EndpointName: "log", - Version: 2, - Topic: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "logs"}, - Brokers: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "127.0.0.1,127.0.0.2"}, - ParseLogKeyvals: common.OptionalBool{Optional: common.Optional{WasSet: true}, Value: true}, - RequestMaxBytes: common.OptionalUint{Optional: common.Optional{WasSet: true}, Value: 11111}, - UseSASL: common.OptionalBool{Optional: common.Optional{WasSet: true}, Value: true}, - AuthMethod: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: authMethod}, - User: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: user}, - Password: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: password}, - } -} - -func updateCommandNoSASL() *UpdateCommand { - return &UpdateCommand{ - Base: common.Base{Globals: &config.Data{Client: nil}}, - manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, - EndpointName: "log", - Version: 2, - Topic: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "logs"}, - Brokers: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "127.0.0.1,127.0.0.2"}, - ParseLogKeyvals: common.OptionalBool{Optional: common.Optional{WasSet: true}, Value: true}, - RequestMaxBytes: common.OptionalUint{Optional: common.Optional{WasSet: true}, Value: 11111}, - UseSASL: common.OptionalBool{Optional: common.Optional{WasSet: true}, Value: false}, - AuthMethod: common.OptionalString{Optional: common.Optional{WasSet: false}, Value: ""}, - User: common.OptionalString{Optional: common.Optional{WasSet: false}, Value: ""}, - Password: common.OptionalString{Optional: common.Optional{WasSet: false}, Value: ""}, - } -} - -func updateCommandMissingServiceID() *UpdateCommand { - res := updateCommandAll() - res.manifest = manifest.Data{} - return res -} - -func getKafkaOK(i *fastly.GetKafkaInput) (*fastly.Kafka, error) { - return &fastly.Kafka{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "log", - Brokers: "127.0.0.1,127.0.0.2", - Topic: "logs", - RequiredACKs: "-1", - UseTLS: true, - CompressionCodec: "zippy", - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - ResponseCondition: "Prevent default logging", - Placement: "none", - TLSCACert: "-----BEGIN CERTIFICATE-----foo", - TLSHostname: "example.com", - TLSClientCert: "-----BEGIN CERTIFICATE-----bar", - TLSClientKey: "-----BEGIN PRIVATE KEY-----bar", - ParseLogKeyvals: false, - RequestMaxBytes: 0, - AuthMethod: "", - User: "", - Password: "", - }, nil -} - -func getKafkaSASL(i *fastly.GetKafkaInput) (*fastly.Kafka, error) { - return &fastly.Kafka{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "log", - Brokers: "127.0.0.1,127.0.0.2", - Topic: "logs", - RequiredACKs: "-1", - UseTLS: true, - CompressionCodec: "zippy", - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - ResponseCondition: "Prevent default logging", - Placement: "none", - TLSCACert: "-----BEGIN CERTIFICATE-----foo", - TLSHostname: "example.com", - TLSClientCert: "-----BEGIN CERTIFICATE-----bar", - TLSClientKey: "-----BEGIN PRIVATE KEY-----bar", - ParseLogKeyvals: false, - RequestMaxBytes: 0, - AuthMethod: "plain", - User: "user", - Password: "password", - }, nil -} diff --git a/pkg/logging/kafka/list.go b/pkg/logging/kafka/list.go deleted file mode 100644 index 9915ff5bf..000000000 --- a/pkg/logging/kafka/list.go +++ /dev/null @@ -1,86 +0,0 @@ -package kafka - -import ( - "fmt" - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// ListCommand calls the Fastly API to list Kafka logging endpoints. -type ListCommand struct { - common.Base - manifest manifest.Data - Input fastly.ListKafkasInput -} - -// NewListCommand returns a usable command registered under the parent. -func NewListCommand(parent common.Registerer, globals *config.Data) *ListCommand { - var c ListCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("list", "List Kafka endpoints on a Fastly service version") - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Input.ServiceVersion) - return &c -} - -// Exec invokes the application logic for the command. -func (c *ListCommand) Exec(in io.Reader, out io.Writer) error { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - c.Input.ServiceID = serviceID - - kafkas, err := c.Globals.Client.ListKafkas(&c.Input) - if err != nil { - return err - } - - if !c.Globals.Verbose() { - tw := text.NewTable(out) - tw.AddHeader("SERVICE", "VERSION", "NAME") - for _, kafka := range kafkas { - tw.AddLine(kafka.ServiceID, kafka.ServiceVersion, kafka.Name) - } - tw.Print() - return nil - } - - fmt.Fprintf(out, "Service ID: %s\n", c.Input.ServiceID) - fmt.Fprintf(out, "Version: %d\n", c.Input.ServiceVersion) - for i, kafka := range kafkas { - fmt.Fprintf(out, "\tKafka %d/%d\n", i+1, len(kafkas)) - fmt.Fprintf(out, "\t\tService ID: %s\n", kafka.ServiceID) - fmt.Fprintf(out, "\t\tVersion: %d\n", kafka.ServiceVersion) - fmt.Fprintf(out, "\t\tName: %s\n", kafka.Name) - fmt.Fprintf(out, "\t\tTopic: %s\n", kafka.Topic) - fmt.Fprintf(out, "\t\tBrokers: %s\n", kafka.Brokers) - fmt.Fprintf(out, "\t\tRequired acks: %s\n", kafka.RequiredACKs) - fmt.Fprintf(out, "\t\tCompression codec: %s\n", kafka.CompressionCodec) - fmt.Fprintf(out, "\t\tUse TLS: %t\n", kafka.UseTLS) - fmt.Fprintf(out, "\t\tTLS CA certificate: %s\n", kafka.TLSCACert) - fmt.Fprintf(out, "\t\tTLS client certificate: %s\n", kafka.TLSClientCert) - fmt.Fprintf(out, "\t\tTLS client key: %s\n", kafka.TLSClientKey) - fmt.Fprintf(out, "\t\tTLS hostname: %s\n", kafka.TLSHostname) - fmt.Fprintf(out, "\t\tFormat: %s\n", kafka.Format) - fmt.Fprintf(out, "\t\tFormat version: %d\n", kafka.FormatVersion) - fmt.Fprintf(out, "\t\tResponse condition: %s\n", kafka.ResponseCondition) - fmt.Fprintf(out, "\t\tPlacement: %s\n", kafka.Placement) - fmt.Fprintf(out, "\t\tParse log key-values: %t\n", kafka.ParseLogKeyvals) - fmt.Fprintf(out, "\t\tMax batch size: %d\n", kafka.RequestMaxBytes) - fmt.Fprintf(out, "\t\tSASL authentication method: %s\n", kafka.AuthMethod) - fmt.Fprintf(out, "\t\tSASL authentication username: %s\n", kafka.User) - fmt.Fprintf(out, "\t\tSASL authentication password: %s\n", kafka.Password) - } - fmt.Fprintln(out) - - return nil -} diff --git a/pkg/logging/kafka/root.go b/pkg/logging/kafka/root.go deleted file mode 100644 index 0abeaca07..000000000 --- a/pkg/logging/kafka/root.go +++ /dev/null @@ -1,28 +0,0 @@ -package kafka - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/config" -) - -// RootCommand is the parent command for all subcommands in this package. -// It should be installed under the primary root command. -type RootCommand struct { - common.Base - // no flags -} - -// NewRootCommand returns a new command registered in the parent. -func NewRootCommand(parent common.Registerer, globals *config.Data) *RootCommand { - var c RootCommand - c.Globals = globals - c.CmdClause = parent.Command("kafka", "Manipulate Fastly service version Kafka logging endpoints") - return &c -} - -// Exec implements the command interface. -func (c *RootCommand) Exec(in io.Reader, out io.Writer) error { - panic("unreachable") -} diff --git a/pkg/logging/kafka/update.go b/pkg/logging/kafka/update.go deleted file mode 100644 index 4d209ad5a..000000000 --- a/pkg/logging/kafka/update.go +++ /dev/null @@ -1,206 +0,0 @@ -package kafka - -import ( - "fmt" - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// UpdateCommand calls the Fastly API to update a Kafka logging endpoint. -type UpdateCommand struct { - common.Base - manifest manifest.Data - - // required - EndpointName string // Can't shadow common.Base method Name(). - Version int - - // optional - NewName common.OptionalString - Index common.OptionalString - Topic common.OptionalString - Brokers common.OptionalString - UseTLS common.OptionalBool - CompressionCodec common.OptionalString - RequiredACKs common.OptionalString - TLSCACert common.OptionalString - TLSClientCert common.OptionalString - TLSClientKey common.OptionalString - TLSHostname common.OptionalString - Format common.OptionalString - FormatVersion common.OptionalUint - Placement common.OptionalString - ResponseCondition common.OptionalString - ParseLogKeyvals common.OptionalBool - RequestMaxBytes common.OptionalUint - UseSASL common.OptionalBool - AuthMethod common.OptionalString - User common.OptionalString - Password common.OptionalString -} - -// NewUpdateCommand returns a usable command registered under the parent. -func NewUpdateCommand(parent common.Registerer, globals *config.Data) *UpdateCommand { - var c UpdateCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - - c.CmdClause = parent.Command("update", "Update a Kafka logging endpoint on a Fastly service version") - - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Version) - c.CmdClause.Flag("name", "The name of the Kafka logging object").Short('n').Required().StringVar(&c.EndpointName) - - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("new-name", "New name of the Kafka logging object").Action(c.NewName.Set).StringVar(&c.NewName.Value) - c.CmdClause.Flag("topic", "The Kafka topic to send logs to").Action(c.Topic.Set).StringVar(&c.Topic.Value) - c.CmdClause.Flag("brokers", "A comma-separated list of IP addresses or hostnames of Kafka brokers").Action(c.Brokers.Set).StringVar(&c.Brokers.Value) - c.CmdClause.Flag("compression-codec", "The codec used for compression of your logs. One of: gzip, snappy, lz4").Action(c.CompressionCodec.Set).StringVar(&c.CompressionCodec.Value) - c.CmdClause.Flag("required-acks", "The Number of acknowledgements a leader must receive before a write is considered successful. One of: 1 (default) One server needs to respond. 0 No servers need to respond. -1 Wait for all in-sync replicas to respond").Action(c.RequiredACKs.Set).StringVar(&c.RequiredACKs.Value) - c.CmdClause.Flag("use-tls", "Whether to use TLS for secure logging. Can be either true or false").Action(c.UseTLS.Set).BoolVar(&c.UseTLS.Value) - c.CmdClause.Flag("tls-ca-cert", "A secure certificate to authenticate the server with. Must be in PEM format").Action(c.TLSCACert.Set).StringVar(&c.TLSCACert.Value) - c.CmdClause.Flag("tls-client-cert", "The client certificate used to make authenticated requests. Must be in PEM format").Action(c.TLSClientCert.Set).StringVar(&c.TLSClientCert.Value) - c.CmdClause.Flag("tls-client-key", "The client private key used to make authenticated requests. Must be in PEM format").Action(c.TLSClientKey.Set).StringVar(&c.TLSClientKey.Value) - c.CmdClause.Flag("tls-hostname", "The hostname used to verify the server's certificate. It can either be the Common Name or a Subject Alternative Name (SAN)").Action(c.TLSHostname.Set).StringVar(&c.TLSHostname.Value) - c.CmdClause.Flag("format", "Apache style log formatting. Your log must produce valid JSON that Kafka can ingest").Action(c.Format.Set).StringVar(&c.Format.Value) - c.CmdClause.Flag("format-version", "The version of the custom logging format used for the configured endpoint. Can be either 2 (default) or 1").Action(c.FormatVersion.Set).UintVar(&c.FormatVersion.Value) - c.CmdClause.Flag("placement", "Where in the generated VCL the logging call should be placed, overriding any format_version default. Can be none or waf_debug").Action(c.Placement.Set).StringVar(&c.Placement.Value) - c.CmdClause.Flag("response-condition", "The name of an existing condition in the configured endpoint, or leave blank to always execute").Action(c.ResponseCondition.Set).StringVar(&c.ResponseCondition.Value) - c.CmdClause.Flag("parse-log-keyvals", "Parse key-value pairs within the log format").Action(c.ParseLogKeyvals.Set).NegatableBoolVar(&c.ParseLogKeyvals.Value) - c.CmdClause.Flag("max-batch-size", "The maximum size of the log batch in bytes").Action(c.RequestMaxBytes.Set).UintVar(&c.RequestMaxBytes.Value) - c.CmdClause.Flag("use-sasl", "Enable SASL authentication. Requires --auth-method, --username, and --password to be specified").Action(c.UseSASL.Set).BoolVar(&c.UseSASL.Value) - c.CmdClause.Flag("auth-method", "SASL authentication method. Valid values are: plain, scram-sha-256, scram-sha-512").Action(c.AuthMethod.Set).HintOptions("plain", "scram-sha-256", "scram-sha-512").EnumVar(&c.AuthMethod.Value, "plain", "scram-sha-256", "scram-sha-512") - c.CmdClause.Flag("username", "SASL authentication username. Required if --auth-method is specified").Action(c.User.Set).StringVar(&c.User.Value) - c.CmdClause.Flag("password", "SASL authentication password. Required if --auth-method is specified").Action(c.Password.Set).StringVar(&c.Password.Value) - - return &c -} - -// createInput transforms values parsed from CLI flags into an object to be used by the API client library. -func (c *UpdateCommand) createInput() (*fastly.UpdateKafkaInput, error) { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return nil, errors.ErrNoServiceID - } - - if c.UseSASL.WasSet && c.UseSASL.Value && (c.AuthMethod.Value == "" || c.User.Value == "" || c.Password.Value == "") { - return nil, fmt.Errorf("the --auth-method, --username, and --password flags must be present when using the --use-sasl flag") - } - - if !c.UseSASL.Value && (c.AuthMethod.Value != "" || c.User.Value != "" || c.Password.Value != "") { - return nil, fmt.Errorf("the --auth-method, --username, and --password options are only valid when the --use-sasl flag is specified") - } - - input := fastly.UpdateKafkaInput{ - ServiceID: serviceID, - ServiceVersion: c.Version, - Name: c.EndpointName, - } - - if c.NewName.WasSet { - input.NewName = fastly.String(c.NewName.Value) - } - - if c.Topic.WasSet { - input.Topic = fastly.String(c.Topic.Value) - } - - if c.Brokers.WasSet { - input.Brokers = fastly.String(c.Brokers.Value) - } - - if c.CompressionCodec.WasSet { - input.CompressionCodec = fastly.String(c.CompressionCodec.Value) - } - - if c.RequiredACKs.WasSet { - input.RequiredACKs = fastly.String(c.RequiredACKs.Value) - } - - if c.UseTLS.WasSet { - input.UseTLS = fastly.CBool(c.UseTLS.Value) - } - - if c.TLSCACert.WasSet { - input.TLSCACert = fastly.String(c.TLSCACert.Value) - } - - if c.TLSClientCert.WasSet { - input.TLSClientCert = fastly.String(c.TLSClientCert.Value) - } - - if c.TLSClientKey.WasSet { - input.TLSClientKey = fastly.String(c.TLSClientKey.Value) - } - - if c.TLSHostname.WasSet { - input.TLSHostname = fastly.String(c.TLSHostname.Value) - } - - if c.Format.WasSet { - input.Format = fastly.String(c.Format.Value) - } - - if c.FormatVersion.WasSet { - input.FormatVersion = fastly.Uint(c.FormatVersion.Value) - } - - if c.ResponseCondition.WasSet { - input.ResponseCondition = fastly.String(c.ResponseCondition.Value) - } - - if c.Placement.WasSet { - input.Placement = fastly.String(c.Placement.Value) - } - - if c.ParseLogKeyvals.WasSet { - input.ParseLogKeyvals = fastly.CBool(c.ParseLogKeyvals.Value) - } - - if c.RequestMaxBytes.WasSet { - input.RequestMaxBytes = fastly.Uint(c.RequestMaxBytes.Value) - } - - if c.UseSASL.WasSet && !c.UseSASL.Value { - input.AuthMethod = fastly.String("") - input.User = fastly.String("") - input.Password = fastly.String("") - } - - if c.AuthMethod.WasSet { - input.AuthMethod = fastly.String(c.AuthMethod.Value) - - } - - if c.User.WasSet { - input.User = fastly.String(c.User.Value) - } - - if c.Password.WasSet { - input.Password = fastly.String(c.Password.Value) - } - - return &input, nil -} - -// Exec invokes the application logic for the command. -func (c *UpdateCommand) Exec(in io.Reader, out io.Writer) error { - input, err := c.createInput() - if err != nil { - return err - } - - kafka, err := c.Globals.Client.UpdateKafka(input) - if err != nil { - return err - } - - text.Success(out, "Updated Kafka logging endpoint %s (service %s version %d)", kafka.Name, kafka.ServiceID, kafka.ServiceVersion) - return nil -} diff --git a/pkg/logging/kinesis/create.go b/pkg/logging/kinesis/create.go deleted file mode 100644 index 214e116c8..000000000 --- a/pkg/logging/kinesis/create.go +++ /dev/null @@ -1,147 +0,0 @@ -package kinesis - -import ( - "fmt" - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// CreateCommand calls the Fastly API to create an Amazon Kinesis logging endpoint. -type CreateCommand struct { - common.Base - manifest manifest.Data - - // required - EndpointName string // Can't shadow common.Base method Name(). - Version int - StreamName string - Region string - - // mutual exclusions - // AccessKey + SecretKey or IAMRole must be provided - AccessKey common.OptionalString - SecretKey common.OptionalString - IAMRole common.OptionalString - - // optional - Format common.OptionalString - FormatVersion common.OptionalUint - ResponseCondition common.OptionalString - Placement common.OptionalString -} - -// NewCreateCommand returns a usable command registered under the parent. -func NewCreateCommand(parent common.Registerer, globals *config.Data) *CreateCommand { - var c CreateCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("create", "Create an Amazon Kinesis logging endpoint on a Fastly service version").Alias("add") - - // required - c.CmdClause.Flag("name", "The name of the Kinesis logging object. Used as a primary key for API access").Short('n').Required().StringVar(&c.EndpointName) - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Version) - c.CmdClause.Flag("stream-name", "The Amazon Kinesis stream to send logs to").Required().StringVar(&c.StreamName) - c.CmdClause.Flag("region", "The AWS region where the Kinesis stream exists").Required().StringVar(&c.Region) - - // required, but mutually exclusive - c.CmdClause.Flag("access-key", "The access key associated with the target Amazon Kinesis stream").Action(c.AccessKey.Set).StringVar(&c.AccessKey.Value) - c.CmdClause.Flag("secret-key", "The secret key associated with the target Amazon Kinesis stream").Action(c.SecretKey.Set).StringVar(&c.SecretKey.Value) - c.CmdClause.Flag("iam-role", "The IAM role ARN for logging").Action(c.IAMRole.Set).StringVar(&c.IAMRole.Value) - - // optional - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("format", "Apache style log formatting").Action(c.Format.Set).StringVar(&c.Format.Value) - c.CmdClause.Flag("format-version", "The version of the custom logging format used for the configured endpoint. Can be either 2 (default) or 1").Action(c.FormatVersion.Set).UintVar(&c.FormatVersion.Value) - c.CmdClause.Flag("response-condition", "The name of an existing condition in the configured endpoint, or leave blank to always execute").Action(c.ResponseCondition.Set).StringVar(&c.ResponseCondition.Value) - c.CmdClause.Flag("placement", "Where in the generated VCL the logging call should be placed, overriding any format_version default. Can be none or waf_debug").Action(c.Placement.Set).StringVar(&c.Placement.Value) - - return &c -} - -// createInput transforms values parsed from CLI flags into an object to be used by the API client library. -func (c *CreateCommand) createInput() (*fastly.CreateKinesisInput, error) { - var input fastly.CreateKinesisInput - - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return nil, errors.ErrNoServiceID - } - - input.ServiceID = serviceID - input.ServiceVersion = c.Version - input.Name = c.EndpointName - input.StreamName = c.StreamName - input.Region = c.Region - - // The following block checks for invalid permutations of the ways in - // which the AccessKey + SecretKey and IAMRole flags can be - // provided. This is necessary because either the AccessKey and - // SecretKey or the IAMRole is required, but they are mutually - // exclusive. The kingpin library lacks a way to express this constraint - // via the flag specification API so we enforce it manually here. - if !c.AccessKey.WasSet && !c.SecretKey.WasSet && !c.IAMRole.WasSet { - return nil, fmt.Errorf("error parsing arguments: the --access-key and --secret-key flags or the --iam-role flag must be provided") - } else if (c.AccessKey.WasSet || c.SecretKey.WasSet) && c.IAMRole.WasSet { - // Enforce mutual exclusion - return nil, fmt.Errorf("error parsing arguments: the --access-key and --secret-key flags are mutually exclusive with the --iam-role flag") - } else if c.AccessKey.WasSet && !c.SecretKey.WasSet { - return nil, fmt.Errorf("error parsing arguments: required flag --secret-key not provided") - - } else if !c.AccessKey.WasSet && c.SecretKey.WasSet { - return nil, fmt.Errorf("error parsing arguments: required flag --access-key not provided") - - } - - if c.AccessKey.WasSet { - input.AccessKey = c.AccessKey.Value - } - - if c.SecretKey.WasSet { - input.SecretKey = c.SecretKey.Value - } - - if c.IAMRole.WasSet { - input.IAMRole = c.IAMRole.Value - } - - if c.Format.WasSet { - input.Format = c.Format.Value - } - - if c.FormatVersion.WasSet { - input.FormatVersion = c.FormatVersion.Value - } - - if c.ResponseCondition.WasSet { - input.ResponseCondition = c.ResponseCondition.Value - } - - if c.Placement.WasSet { - input.Placement = c.Placement.Value - } - - return &input, nil -} - -// Exec invokes the application logic for the command. -func (c *CreateCommand) Exec(in io.Reader, out io.Writer) error { - input, err := c.createInput() - if err != nil { - return err - } - - d, err := c.Globals.Client.CreateKinesis(input) - if err != nil { - return err - } - - text.Success(out, "Created Kinesis logging endpoint %s (service %s version %d)", d.Name, d.ServiceID, d.ServiceVersion) - return nil -} diff --git a/pkg/logging/kinesis/delete.go b/pkg/logging/kinesis/delete.go deleted file mode 100644 index 89bd00d52..000000000 --- a/pkg/logging/kinesis/delete.go +++ /dev/null @@ -1,49 +0,0 @@ -package kinesis - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// DeleteCommand calls the Fastly API to delete an Amazon Kinesis logging endpoint. -type DeleteCommand struct { - common.Base - manifest manifest.Data - Input fastly.DeleteKinesisInput -} - -// NewDeleteCommand returns a usable command registered under the parent. -func NewDeleteCommand(parent common.Registerer, globals *config.Data) *DeleteCommand { - var c DeleteCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("delete", "Delete a Kinesis logging endpoint on a Fastly service version").Alias("remove") - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Input.ServiceVersion) - c.CmdClause.Flag("name", "The name of the Kinesis logging object").Short('n').Required().StringVar(&c.Input.Name) - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - - return &c -} - -// Exec invokes the application logic for the command. -func (c *DeleteCommand) Exec(in io.Reader, out io.Writer) error { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - c.Input.ServiceID = serviceID - - if err := c.Globals.Client.DeleteKinesis(&c.Input); err != nil { - return err - } - - text.Success(out, "Deleted Kinesis logging endpoint %s (service %s version %d)", c.Input.Name, c.Input.ServiceID, c.Input.ServiceVersion) - return nil -} diff --git a/pkg/logging/kinesis/describe.go b/pkg/logging/kinesis/describe.go deleted file mode 100644 index 82472a01b..000000000 --- a/pkg/logging/kinesis/describe.go +++ /dev/null @@ -1,65 +0,0 @@ -package kinesis - -import ( - "fmt" - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/go-fastly/v3/fastly" -) - -// DescribeCommand calls the Fastly API to describe an Amazon Kinesis logging endpoint. -type DescribeCommand struct { - common.Base - manifest manifest.Data - Input fastly.GetKinesisInput -} - -// NewDescribeCommand returns a usable command registered under the parent. -func NewDescribeCommand(parent common.Registerer, globals *config.Data) *DescribeCommand { - var c DescribeCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("describe", "Show detailed information about a Kinesis logging endpoint on a Fastly service version").Alias("get") - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Input.ServiceVersion) - c.CmdClause.Flag("name", "The name of the Kinesis logging object").Short('n').Required().StringVar(&c.Input.Name) - return &c -} - -// Exec invokes the application logic for the command. -func (c *DescribeCommand) Exec(in io.Reader, out io.Writer) error { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - c.Input.ServiceID = serviceID - - kinesis, err := c.Globals.Client.GetKinesis(&c.Input) - if err != nil { - return err - } - - fmt.Fprintf(out, "Service ID: %s\n", kinesis.ServiceID) - fmt.Fprintf(out, "Version: %d\n", kinesis.ServiceVersion) - fmt.Fprintf(out, "Name: %s\n", kinesis.Name) - fmt.Fprintf(out, "Stream name: %s\n", kinesis.StreamName) - fmt.Fprintf(out, "Region: %s\n", kinesis.Region) - if kinesis.AccessKey != "" || kinesis.SecretKey != "" { - fmt.Fprintf(out, "Access key: %s\n", kinesis.AccessKey) - fmt.Fprintf(out, "Secret key: %s\n", kinesis.SecretKey) - } - if kinesis.IAMRole != "" { - fmt.Fprintf(out, "IAM role: %s\n", kinesis.IAMRole) - } - fmt.Fprintf(out, "Format: %s\n", kinesis.Format) - fmt.Fprintf(out, "Format version: %d\n", kinesis.FormatVersion) - fmt.Fprintf(out, "Response condition: %s\n", kinesis.ResponseCondition) - fmt.Fprintf(out, "Placement: %s\n", kinesis.Placement) - - return nil -} diff --git a/pkg/logging/kinesis/kinesis_integration_test.go b/pkg/logging/kinesis/kinesis_integration_test.go deleted file mode 100644 index b26ed4096..000000000 --- a/pkg/logging/kinesis/kinesis_integration_test.go +++ /dev/null @@ -1,416 +0,0 @@ -package kinesis_test - -import ( - "bytes" - "errors" - "io" - "net/http" - "strings" - "testing" - - "github.com/fastly/cli/pkg/app" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/mock" - "github.com/fastly/cli/pkg/testutil" - "github.com/fastly/cli/pkg/update" - "github.com/fastly/go-fastly/v3/fastly" -) - -func TestKinesisCreate(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "kinesis", "create", "--service-id", "123", "--version", "1", "--name", "log", "--stream-name", "log", "--access-key", "foo", "--region", "us-east-1"}, - wantError: "error parsing arguments: required flag --secret-key not provided", - }, - { - args: []string{"logging", "kinesis", "create", "--service-id", "123", "--version", "1", "--name", "log", "--stream-name", "log", "--region", "us-east-1", "--access-key", "foo"}, - wantError: "error parsing arguments: required flag --secret-key not provided", - }, - { - args: []string{"logging", "kinesis", "create", "--service-id", "123", "--version", "1", "--name", "log", "--stream-name", "log", "--region", "us-east-1", "--secret-key", "bar"}, - wantError: "error parsing arguments: required flag --access-key not provided", - }, - { - args: []string{"logging", "kinesis", "create", "--service-id", "123", "--version", "1", "--name", "log", "--stream-name", "log", "--region", "us-east-1", "--secret-key", "bar", "--iam-role", "arn:aws:iam::123456789012:role/KinesisAccess"}, - wantError: "error parsing arguments: the --access-key and --secret-key flags are mutually exclusive with the --iam-role flag", - }, - { - args: []string{"logging", "kinesis", "create", "--service-id", "123", "--version", "1", "--name", "log", "--stream-name", "log", "--region", "us-east-1", "--access-key", "foo", "--iam-role", "arn:aws:iam::123456789012:role/KinesisAccess"}, - wantError: "error parsing arguments: the --access-key and --secret-key flags are mutually exclusive with the --iam-role flag", - }, - { - args: []string{"logging", "kinesis", "create", "--service-id", "123", "--version", "1", "--name", "log", "--stream-name", "log", "--region", "us-east-1", "--access-key", "foo", "--secret-key", "bar", "--iam-role", "arn:aws:iam::123456789012:role/KinesisAccess"}, - wantError: "error parsing arguments: the --access-key and --secret-key flags are mutually exclusive with the --iam-role flag", - }, - { - args: []string{"logging", "kinesis", "create", "--service-id", "123", "--version", "1", "--name", "log", "--stream-name", "log", "--access-key", "foo", "--secret-key", "bar", "--region", "us-east-1"}, - api: mock.API{CreateKinesisFn: createKinesisOK}, - wantOutput: "Created Kinesis logging endpoint log (service 123 version 1)", - }, - { - args: []string{"logging", "kinesis", "create", "--service-id", "123", "--version", "1", "--name", "log", "--stream-name", "log", "--access-key", "foo", "--secret-key", "bar", "--region", "us-east-1"}, - api: mock.API{CreateKinesisFn: createKinesisError}, - wantError: errTest.Error(), - }, - { - args: []string{"logging", "kinesis", "create", "--service-id", "123", "--version", "1", "--name", "log2", "--stream-name", "log", "--region", "us-east-1", "--iam-role", "arn:aws:iam::123456789012:role/KinesisAccess"}, - api: mock.API{CreateKinesisFn: createKinesisOK}, - wantOutput: "Created Kinesis logging endpoint log2 (service 123 version 1)", - }, - { - args: []string{"logging", "kinesis", "create", "--service-id", "123", "--version", "1", "--name", "log2", "--stream-name", "log", "--region", "us-east-1", "--iam-role", "arn:aws:iam::123456789012:role/KinesisAccess"}, - api: mock.API{CreateKinesisFn: createKinesisError}, - wantError: errTest.Error(), - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertStringContains(t, out.String(), testcase.wantOutput) - }) - } -} - -func TestKinesisList(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "kinesis", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListKinesisFn: listKinesesOK}, - wantOutput: listKinesesShortOutput, - }, - { - args: []string{"logging", "kinesis", "list", "--service-id", "123", "--version", "1", "--verbose"}, - api: mock.API{ListKinesisFn: listKinesesOK}, - wantOutput: listKinesesVerboseOutput, - }, - { - args: []string{"logging", "kinesis", "list", "--service-id", "123", "--version", "1", "-v"}, - api: mock.API{ListKinesisFn: listKinesesOK}, - wantOutput: listKinesesVerboseOutput, - }, - { - args: []string{"logging", "kinesis", "--verbose", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListKinesisFn: listKinesesOK}, - wantOutput: listKinesesVerboseOutput, - }, - { - args: []string{"logging", "-v", "kinesis", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListKinesisFn: listKinesesOK}, - wantOutput: listKinesesVerboseOutput, - }, - { - args: []string{"logging", "kinesis", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListKinesisFn: listKinesesError}, - wantError: errTest.Error(), - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertString(t, testcase.wantOutput, out.String()) - }) - } -} - -func TestKinesisDescribe(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "kinesis", "describe", "--service-id", "123", "--version", "1"}, - wantError: "error parsing arguments: required flag --name not provided", - }, - { - args: []string{"logging", "kinesis", "describe", "--service-id", "123", "--version", "1", "--name", "logs"}, - api: mock.API{GetKinesisFn: getKinesisError}, - wantError: errTest.Error(), - }, - { - args: []string{"logging", "kinesis", "describe", "--service-id", "123", "--version", "1", "--name", "logs"}, - api: mock.API{GetKinesisFn: getKinesisOK}, - wantOutput: describeKinesisOutput, - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertString(t, testcase.wantOutput, out.String()) - }) - } -} - -func TestKinesisUpdate(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "kinesis", "update", "--service-id", "123", "--version", "1", "--new-name", "log"}, - wantError: "error parsing arguments: required flag --name not provided", - }, - { - args: []string{"logging", "kinesis", "update", "--service-id", "123", "--version", "1", "--name", "logs", "--new-name", "log"}, - api: mock.API{UpdateKinesisFn: updateKinesisError}, - wantError: errTest.Error(), - }, - { - args: []string{"logging", "kinesis", "update", "--service-id", "123", "--version", "1", "--name", "logs", "--new-name", "log", "--region", "us-west-1"}, - api: mock.API{UpdateKinesisFn: updateKinesisOK}, - wantOutput: "Updated Kinesis logging endpoint log (service 123 version 1)", - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertStringContains(t, out.String(), testcase.wantOutput) - }) - } -} - -func TestKinesisDelete(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "kinesis", "delete", "--service-id", "123", "--version", "1"}, - wantError: "error parsing arguments: required flag --name not provided", - }, - { - args: []string{"logging", "kinesis", "delete", "--service-id", "123", "--version", "1", "--name", "logs"}, - api: mock.API{DeleteKinesisFn: deleteKinesisError}, - wantError: errTest.Error(), - }, - { - args: []string{"logging", "kinesis", "delete", "--service-id", "123", "--version", "1", "--name", "logs"}, - api: mock.API{DeleteKinesisFn: deleteKinesisOK}, - wantOutput: "Deleted Kinesis logging endpoint logs (service 123 version 1)", - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertStringContains(t, out.String(), testcase.wantOutput) - }) - } -} - -var errTest = errors.New("fixture error") - -func createKinesisOK(i *fastly.CreateKinesisInput) (*fastly.Kinesis, error) { - return &fastly.Kinesis{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: i.Name, - }, nil -} - -func createKinesisError(i *fastly.CreateKinesisInput) (*fastly.Kinesis, error) { - return nil, errTest -} - -func listKinesesOK(i *fastly.ListKinesisInput) ([]*fastly.Kinesis, error) { - return []*fastly.Kinesis{ - { - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "logs", - StreamName: "my-logs", - AccessKey: "1234", - SecretKey: "-----BEGIN RSA PRIVATE KEY-----MIIEogIBAAKCA", - Region: "us-east-1", - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - ResponseCondition: "Prevent default logging", - Placement: "none", - }, - { - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "analytics", - StreamName: "analytics", - AccessKey: "1234", - SecretKey: "-----BEGIN RSA PRIVATE KEY-----MIIEogIBAAKCA", - Region: "us-east-1", - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - ResponseCondition: "Prevent default logging", - Placement: "none", - }, - }, nil -} - -func listKinesesError(i *fastly.ListKinesisInput) ([]*fastly.Kinesis, error) { - return nil, errTest -} - -var listKinesesShortOutput = strings.TrimSpace(` -SERVICE VERSION NAME -123 1 logs -123 1 analytics -`) + "\n" - -var listKinesesVerboseOutput = strings.TrimSpace(` -Fastly API token not provided -Fastly API endpoint: https://api.fastly.com -Service ID: 123 -Version: 1 - Kinesis 1/2 - Service ID: 123 - Version: 1 - Name: logs - Stream name: my-logs - Region: us-east-1 - Access key: 1234 - Secret key: -----BEGIN RSA PRIVATE KEY-----MIIEogIBAAKCA - Format: %h %l %u %t "%r" %>s %b - Format version: 2 - Response condition: Prevent default logging - Placement: none - Kinesis 2/2 - Service ID: 123 - Version: 1 - Name: analytics - Stream name: analytics - Region: us-east-1 - Access key: 1234 - Secret key: -----BEGIN RSA PRIVATE KEY-----MIIEogIBAAKCA - Format: %h %l %u %t "%r" %>s %b - Format version: 2 - Response condition: Prevent default logging - Placement: none -`) + "\n\n" - -func getKinesisOK(i *fastly.GetKinesisInput) (*fastly.Kinesis, error) { - return &fastly.Kinesis{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "logs", - StreamName: "my-logs", - AccessKey: "1234", - SecretKey: "-----BEGIN RSA PRIVATE KEY-----MIIEogIBAAKCA", - Region: "us-east-1", - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - ResponseCondition: "Prevent default logging", - Placement: "none", - }, nil -} - -func getKinesisError(i *fastly.GetKinesisInput) (*fastly.Kinesis, error) { - return nil, errTest -} - -var describeKinesisOutput = strings.TrimSpace(` -Service ID: 123 -Version: 1 -Name: logs -Stream name: my-logs -Region: us-east-1 -Access key: 1234 -Secret key: -----BEGIN RSA PRIVATE KEY-----MIIEogIBAAKCA -Format: %h %l %u %t "%r" %>s %b -Format version: 2 -Response condition: Prevent default logging -Placement: none -`) + "\n" - -func updateKinesisOK(i *fastly.UpdateKinesisInput) (*fastly.Kinesis, error) { - return &fastly.Kinesis{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "log", - StreamName: "my-logs", - AccessKey: "1234", - SecretKey: "-----BEGIN RSA PRIVATE KEY-----MIIEogIBAAKCA", - Region: "us-west-1", - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - ResponseCondition: "Prevent default logging", - Placement: "none", - }, nil -} - -func updateKinesisError(i *fastly.UpdateKinesisInput) (*fastly.Kinesis, error) { - return nil, errTest -} - -func deleteKinesisOK(i *fastly.DeleteKinesisInput) error { - return nil -} - -func deleteKinesisError(i *fastly.DeleteKinesisInput) error { - return errTest -} diff --git a/pkg/logging/kinesis/kinesis_test.go b/pkg/logging/kinesis/kinesis_test.go deleted file mode 100644 index af71a9e6e..000000000 --- a/pkg/logging/kinesis/kinesis_test.go +++ /dev/null @@ -1,223 +0,0 @@ -package kinesis - -import ( - "testing" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/mock" - "github.com/fastly/cli/pkg/testutil" - "github.com/fastly/go-fastly/v3/fastly" -) - -func TestCreateKinesisInput(t *testing.T) { - for _, testcase := range []struct { - name string - cmd *CreateCommand - want *fastly.CreateKinesisInput - wantError string - }{ - { - name: "required values set flag serviceID", - cmd: createCommandRequired(), - want: &fastly.CreateKinesisInput{ - ServiceID: "123", - ServiceVersion: 2, - Name: "log", - StreamName: "stream", - AccessKey: "access", - SecretKey: "secret", - }, - }, - { - name: "required values set flag serviceID using IAM role", - cmd: createCommandRequiredIAMRole(), - want: &fastly.CreateKinesisInput{ - ServiceID: "123", - ServiceVersion: 2, - Name: "log", - StreamName: "stream", - IAMRole: "arn:aws:iam::123456789012:role/KinesisAccess", - }, - }, - { - name: "all values set flag serviceID", - cmd: createCommandAll(), - want: &fastly.CreateKinesisInput{ - ServiceID: "123", - ServiceVersion: 2, - Name: "logs", - StreamName: "stream", - Region: "us-east-1", - AccessKey: "access", - SecretKey: "secret", - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - ResponseCondition: "Prevent default logging", - Placement: "none", - }, - }, - { - name: "error missing serviceID", - cmd: createCommandMissingServiceID(), - want: nil, - wantError: errors.ErrNoServiceID.Error(), - }, - } { - t.Run(testcase.name, func(t *testing.T) { - have, err := testcase.cmd.createInput() - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertEqual(t, testcase.want, have) - }) - } -} - -func TestUpdateKinesisInput(t *testing.T) { - for _, testcase := range []struct { - name string - cmd *UpdateCommand - api mock.API - want *fastly.UpdateKinesisInput - wantError string - }{ - { - name: "no updates", - cmd: updateCommandNoUpdates(), - api: mock.API{GetKinesisFn: getKinesisOK}, - want: &fastly.UpdateKinesisInput{ - ServiceID: "123", - ServiceVersion: 2, - Name: "log", - }, - }, - { - name: "all values set flag serviceID", - cmd: updateCommandAll(), - api: mock.API{GetKinesisFn: getKinesisOK}, - want: &fastly.UpdateKinesisInput{ - ServiceID: "123", - ServiceVersion: 2, - Name: "log", - NewName: fastly.String("new1"), - StreamName: fastly.String("new2"), - AccessKey: fastly.String("new3"), - SecretKey: fastly.String("new4"), - IAMRole: fastly.String(""), - Region: fastly.String("new5"), - Format: fastly.String("new7"), - FormatVersion: fastly.Uint(3), - ResponseCondition: fastly.String("new9"), - Placement: fastly.String("new11"), - }, - }, - { - name: "error missing serviceID", - cmd: updateCommandMissingServiceID(), - want: nil, - wantError: errors.ErrNoServiceID.Error(), - }, - } { - t.Run(testcase.name, func(t *testing.T) { - testcase.cmd.Base.Globals.Client = testcase.api - - have, err := testcase.cmd.createInput() - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertEqual(t, testcase.want, have) - }) - } -} - -func createCommandRequired() *CreateCommand { - return &CreateCommand{ - manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, - EndpointName: "log", - Version: 2, - StreamName: "stream", - AccessKey: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "access"}, - SecretKey: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "secret"}, - } -} - -func createCommandRequiredIAMRole() *CreateCommand { - return &CreateCommand{ - manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, - EndpointName: "log", - Version: 2, - StreamName: "stream", - IAMRole: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "arn:aws:iam::123456789012:role/KinesisAccess"}, - } -} - -func createCommandAll() *CreateCommand { - return &CreateCommand{ - manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, - EndpointName: "logs", - Version: 2, - StreamName: "stream", - AccessKey: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "access"}, - SecretKey: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "secret"}, - Region: "us-east-1", - Format: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: `%h %l %u %t "%r" %>s %b`}, - FormatVersion: common.OptionalUint{Optional: common.Optional{WasSet: true}, Value: 2}, - ResponseCondition: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "Prevent default logging"}, - Placement: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "none"}, - } -} - -func createCommandMissingServiceID() *CreateCommand { - res := createCommandAll() - res.manifest = manifest.Data{} - return res -} - -func updateCommandNoUpdates() *UpdateCommand { - return &UpdateCommand{ - Base: common.Base{Globals: &config.Data{Client: nil}}, - manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, - EndpointName: "log", - Version: 2, - } -} - -func updateCommandAll() *UpdateCommand { - return &UpdateCommand{ - Base: common.Base{Globals: &config.Data{Client: nil}}, - manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, - EndpointName: "log", - Version: 2, - NewName: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new1"}, - StreamName: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new2"}, - AccessKey: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new3"}, - SecretKey: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new4"}, - IAMRole: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: ""}, - Region: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new5"}, - Format: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new7"}, - FormatVersion: common.OptionalUint{Optional: common.Optional{WasSet: true}, Value: 3}, - ResponseCondition: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new9"}, - Placement: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new11"}, - } -} - -func updateCommandMissingServiceID() *UpdateCommand { - res := updateCommandAll() - res.manifest = manifest.Data{} - return res -} - -func getKinesisOK(i *fastly.GetKinesisInput) (*fastly.Kinesis, error) { - return &fastly.Kinesis{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "logs", - StreamName: "stream", - Region: "us-east-1", - AccessKey: "access", - SecretKey: "secret", - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - ResponseCondition: "Prevent default logging", - Placement: "none", - }, nil -} diff --git a/pkg/logging/kinesis/list.go b/pkg/logging/kinesis/list.go deleted file mode 100644 index 1866c6335..000000000 --- a/pkg/logging/kinesis/list.go +++ /dev/null @@ -1,81 +0,0 @@ -package kinesis - -import ( - "fmt" - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// ListCommand calls the Fastly API to list Amazon Kinesis logging endpoints. -type ListCommand struct { - common.Base - manifest manifest.Data - Input fastly.ListKinesisInput -} - -// NewListCommand returns a usable command registered under the parent. -func NewListCommand(parent common.Registerer, globals *config.Data) *ListCommand { - var c ListCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("list", "List Kinesis endpoints on a Fastly service version") - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Input.ServiceVersion) - return &c -} - -// Exec invokes the application logic for the command. -func (c *ListCommand) Exec(in io.Reader, out io.Writer) error { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - c.Input.ServiceID = serviceID - - kineses, err := c.Globals.Client.ListKinesis(&c.Input) - if err != nil { - return err - } - - if !c.Globals.Verbose() { - tw := text.NewTable(out) - tw.AddHeader("SERVICE", "VERSION", "NAME") - for _, kinesis := range kineses { - tw.AddLine(kinesis.ServiceID, kinesis.ServiceVersion, kinesis.Name) - } - tw.Print() - return nil - } - - fmt.Fprintf(out, "Service ID: %s\n", c.Input.ServiceID) - fmt.Fprintf(out, "Version: %d\n", c.Input.ServiceVersion) - for i, kinesis := range kineses { - fmt.Fprintf(out, "\tKinesis %d/%d\n", i+1, len(kineses)) - fmt.Fprintf(out, "\t\tService ID: %s\n", kinesis.ServiceID) - fmt.Fprintf(out, "\t\tVersion: %d\n", kinesis.ServiceVersion) - fmt.Fprintf(out, "\t\tName: %s\n", kinesis.Name) - fmt.Fprintf(out, "\t\tStream name: %s\n", kinesis.StreamName) - fmt.Fprintf(out, "\t\tRegion: %s\n", kinesis.Region) - if kinesis.AccessKey != "" || kinesis.SecretKey != "" { - fmt.Fprintf(out, "\t\tAccess key: %s\n", kinesis.AccessKey) - fmt.Fprintf(out, "\t\tSecret key: %s\n", kinesis.SecretKey) - } - if kinesis.IAMRole != "" { - fmt.Fprintf(out, "\t\tIAM role: %s\n", kinesis.IAMRole) - } - fmt.Fprintf(out, "\t\tFormat: %s\n", kinesis.Format) - fmt.Fprintf(out, "\t\tFormat version: %d\n", kinesis.FormatVersion) - fmt.Fprintf(out, "\t\tResponse condition: %s\n", kinesis.ResponseCondition) - fmt.Fprintf(out, "\t\tPlacement: %s\n", kinesis.Placement) - } - fmt.Fprintln(out) - - return nil -} diff --git a/pkg/logging/kinesis/root.go b/pkg/logging/kinesis/root.go deleted file mode 100644 index 3ccbe2bfe..000000000 --- a/pkg/logging/kinesis/root.go +++ /dev/null @@ -1,28 +0,0 @@ -package kinesis - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/config" -) - -// RootCommand is the parent command for all subcommands in this package. -// It should be installed under the primary root command. -type RootCommand struct { - common.Base - // no flags -} - -// NewRootCommand returns a new command registered in the parent. -func NewRootCommand(parent common.Registerer, globals *config.Data) *RootCommand { - var c RootCommand - c.Globals = globals - c.CmdClause = parent.Command("kinesis", "Manipulate a Kinesis logging endpoint for a specific Fastly service version") - return &c -} - -// Exec implements the command interface. -func (c *RootCommand) Exec(in io.Reader, out io.Writer) error { - panic("unreachable") -} diff --git a/pkg/logging/kinesis/update.go b/pkg/logging/kinesis/update.go deleted file mode 100644 index fb1cbd29b..000000000 --- a/pkg/logging/kinesis/update.go +++ /dev/null @@ -1,133 +0,0 @@ -package kinesis - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// UpdateCommand calls the Fastly API to update an Amazon Kinesis logging endpoint. -type UpdateCommand struct { - common.Base - manifest manifest.Data - - // required - EndpointName string // Can't shadow common.Base method Name(). - Version int - - // optional - NewName common.OptionalString - StreamName common.OptionalString - AccessKey common.OptionalString - SecretKey common.OptionalString - IAMRole common.OptionalString - Region common.OptionalString - Format common.OptionalString - FormatVersion common.OptionalUint - ResponseCondition common.OptionalString - Placement common.OptionalString -} - -// NewUpdateCommand returns a usable command registered under the parent. -func NewUpdateCommand(parent common.Registerer, globals *config.Data) *UpdateCommand { - var c UpdateCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - - c.CmdClause = parent.Command("update", "Update a Kinesis logging endpoint on a Fastly service version") - - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Version) - c.CmdClause.Flag("name", "The name of the Kinesis logging object").Short('n').Required().StringVar(&c.EndpointName) - - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("new-name", "New name of the Kinesis logging object").Action(c.NewName.Set).StringVar(&c.NewName.Value) - c.CmdClause.Flag("stream-name", "Your Kinesis stream name").Action(c.StreamName.Set).StringVar(&c.StreamName.Value) - c.CmdClause.Flag("access-key", "Your Kinesis account access key").Action(c.AccessKey.Set).StringVar(&c.AccessKey.Value) - c.CmdClause.Flag("secret-key", "Your Kinesis account secret key").Action(c.SecretKey.Set).StringVar(&c.SecretKey.Value) - c.CmdClause.Flag("iam-role", "The IAM role ARN for logging").Action(c.IAMRole.Set).StringVar(&c.IAMRole.Value) - c.CmdClause.Flag("region", "The AWS region where the Kinesis stream exists").Action(c.Region.Set).StringVar(&c.Region.Value) - c.CmdClause.Flag("format", "Apache style log formatting").Action(c.Format.Set).StringVar(&c.Format.Value) - c.CmdClause.Flag("format-version", "The version of the custom logging format used for the configured endpoint. Can be either 2 (default) or 1").Action(c.FormatVersion.Set).UintVar(&c.FormatVersion.Value) - c.CmdClause.Flag("response-condition", "The name of an existing condition in the configured endpoint, or leave blank to always execute").Action(c.ResponseCondition.Set).StringVar(&c.ResponseCondition.Value) - c.CmdClause.Flag("placement", "Where in the generated VCL the logging call should be placed, overriding any format_version default. Can be none or waf_debug").Action(c.Placement.Set).StringVar(&c.Placement.Value) - - return &c -} - -// createInput transforms values parsed from CLI flags into an object to be used by the API client library. -func (c *UpdateCommand) createInput() (*fastly.UpdateKinesisInput, error) { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return nil, errors.ErrNoServiceID - } - - input := fastly.UpdateKinesisInput{ - ServiceID: serviceID, - ServiceVersion: c.Version, - Name: c.EndpointName, - } - - if c.NewName.WasSet { - input.NewName = fastly.String(c.NewName.Value) - } - - if c.StreamName.WasSet { - input.StreamName = fastly.String(c.StreamName.Value) - } - - if c.AccessKey.WasSet { - input.AccessKey = fastly.String(c.AccessKey.Value) - } - - if c.SecretKey.WasSet { - input.SecretKey = fastly.String(c.SecretKey.Value) - } - - if c.IAMRole.WasSet { - input.IAMRole = fastly.String(c.IAMRole.Value) - } - - if c.Region.WasSet { - input.Region = fastly.String(c.Region.Value) - } - - if c.Format.WasSet { - input.Format = fastly.String(c.Format.Value) - } - - if c.FormatVersion.WasSet { - input.FormatVersion = fastly.Uint(c.FormatVersion.Value) - } - - if c.ResponseCondition.WasSet { - input.ResponseCondition = fastly.String(c.ResponseCondition.Value) - } - - if c.Placement.WasSet { - input.Placement = fastly.String(c.Placement.Value) - } - - return &input, nil -} - -// Exec invokes the application logic for the command. -func (c *UpdateCommand) Exec(in io.Reader, out io.Writer) error { - input, err := c.createInput() - if err != nil { - return err - } - - kinesis, err := c.Globals.Client.UpdateKinesis(input) - if err != nil { - return err - } - - text.Success(out, "Updated Kinesis logging endpoint %s (service %s version %d)", kinesis.Name, kinesis.ServiceID, kinesis.ServiceVersion) - return nil -} diff --git a/pkg/logging/logentries/create.go b/pkg/logging/logentries/create.go deleted file mode 100644 index 84ba54efc..000000000 --- a/pkg/logging/logentries/create.go +++ /dev/null @@ -1,114 +0,0 @@ -package logentries - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// CreateCommand calls the Fastly API to create a Logentries logging endpoint. -type CreateCommand struct { - common.Base - manifest manifest.Data - - // required - EndpointName string // Can't shadow common.Base method Name(). - Version int - - // optional - Port common.OptionalUint - UseTLS common.OptionalBool - Token common.OptionalString - Format common.OptionalString - FormatVersion common.OptionalUint - ResponseCondition common.OptionalString - Placement common.OptionalString -} - -// NewCreateCommand returns a usable command registered under the parent. -func NewCreateCommand(parent common.Registerer, globals *config.Data) *CreateCommand { - var c CreateCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("create", "Create a Logentries logging endpoint on a Fastly service version").Alias("add") - - c.CmdClause.Flag("name", "The name of the Logentries logging object. Used as a primary key for API access").Short('n').Required().StringVar(&c.EndpointName) - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Version) - - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("port", "The port number").Action(c.Port.Set).UintVar(&c.Port.Value) - c.CmdClause.Flag("use-tls", "Whether to use TLS for secure logging. Can be either true or false").Action(c.UseTLS.Set).BoolVar(&c.UseTLS.Value) - c.CmdClause.Flag("auth-token", "Use token based authentication (https://logentries.com/doc/input-token/)").Action(c.Token.Set).StringVar(&c.Token.Value) - c.CmdClause.Flag("format", "Apache style log formatting").Action(c.Format.Set).StringVar(&c.Format.Value) - c.CmdClause.Flag("format-version", "The version of the custom logging format used for the configured endpoint. Can be either 2 (the default, version 2 log format) or 1 (the version 1 log format). The logging call gets placed by default in vcl_log if format_version is set to 2 and in vcl_deliver if format_version is set to 1").Action(c.FormatVersion.Set).UintVar(&c.FormatVersion.Value) - c.CmdClause.Flag("response-condition", "The name of an existing condition in the configured endpoint, or leave blank to always execute").Action(c.ResponseCondition.Set).StringVar(&c.ResponseCondition.Value) - c.CmdClause.Flag("placement", "Where in the generated VCL the logging call should be placed, overriding any format_version default. Can be none or waf_debug. This field is not required and has no default value").Action(c.Placement.Set).StringVar(&c.Placement.Value) - - return &c -} - -// createInput transforms values parsed from CLI flags into an object to be used by the API client library. -func (c *CreateCommand) createInput() (*fastly.CreateLogentriesInput, error) { - var input fastly.CreateLogentriesInput - - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return nil, errors.ErrNoServiceID - } - - input.ServiceID = serviceID - input.ServiceVersion = c.Version - input.Name = c.EndpointName - - if c.Port.WasSet { - input.Port = c.Port.Value - } - - if c.UseTLS.WasSet { - input.UseTLS = fastly.Compatibool(c.UseTLS.Value) - } - - if c.Token.WasSet { - input.Token = c.Token.Value - } - - if c.Format.WasSet { - input.Format = c.Format.Value - } - - if c.FormatVersion.WasSet { - input.FormatVersion = c.FormatVersion.Value - } - - if c.ResponseCondition.WasSet { - input.ResponseCondition = c.ResponseCondition.Value - } - - if c.Placement.WasSet { - input.Placement = c.Placement.Value - } - - return &input, nil -} - -// Exec invokes the application logic for the command. -func (c *CreateCommand) Exec(in io.Reader, out io.Writer) error { - input, err := c.createInput() - if err != nil { - return err - } - - d, err := c.Globals.Client.CreateLogentries(input) - if err != nil { - return err - } - - text.Success(out, "Created Logentries logging endpoint %s (service %s version %d)", d.Name, d.ServiceID, d.ServiceVersion) - return nil -} diff --git a/pkg/logging/logentries/delete.go b/pkg/logging/logentries/delete.go deleted file mode 100644 index 0618bfff0..000000000 --- a/pkg/logging/logentries/delete.go +++ /dev/null @@ -1,51 +0,0 @@ -package logentries - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// DeleteCommand calls the Fastly API to delete a Logentries logging endpoint. -type DeleteCommand struct { - common.Base - manifest manifest.Data - Input fastly.DeleteLogentriesInput -} - -// NewDeleteCommand returns a usable command registered under the parent. -func NewDeleteCommand(parent common.Registerer, globals *config.Data) *DeleteCommand { - var c DeleteCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("delete", "Delete a Logentries logging endpoint on a Fastly service version").Alias("remove") - - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Input.ServiceVersion) - c.CmdClause.Flag("name", "The name of the Logentries logging object").Short('n').Required().StringVar(&c.Input.Name) - - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - - return &c -} - -// Exec invokes the application logic for the command. -func (c *DeleteCommand) Exec(in io.Reader, out io.Writer) error { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - c.Input.ServiceID = serviceID - - if err := c.Globals.Client.DeleteLogentries(&c.Input); err != nil { - return err - } - - text.Success(out, "Deleted Logentries logging endpoint %s (service %s version %d)", c.Input.Name, c.Input.ServiceID, c.Input.ServiceVersion) - return nil -} diff --git a/pkg/logging/logentries/describe.go b/pkg/logging/logentries/describe.go deleted file mode 100644 index 3e7f5af8c..000000000 --- a/pkg/logging/logentries/describe.go +++ /dev/null @@ -1,59 +0,0 @@ -package logentries - -import ( - "fmt" - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/go-fastly/v3/fastly" -) - -// DescribeCommand calls the Fastly API to describe a Logentries logging endpoint. -type DescribeCommand struct { - common.Base - manifest manifest.Data - Input fastly.GetLogentriesInput -} - -// NewDescribeCommand returns a usable command registered under the parent. -func NewDescribeCommand(parent common.Registerer, globals *config.Data) *DescribeCommand { - var c DescribeCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("describe", "Show detailed information about a Logentries logging endpoint on a Fastly service version").Alias("get") - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Input.ServiceVersion) - c.CmdClause.Flag("name", "The name of the Logentries logging object").Short('n').Required().StringVar(&c.Input.Name) - return &c -} - -// Exec invokes the application logic for the command. -func (c *DescribeCommand) Exec(in io.Reader, out io.Writer) error { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - c.Input.ServiceID = serviceID - - logentries, err := c.Globals.Client.GetLogentries(&c.Input) - if err != nil { - return err - } - - fmt.Fprintf(out, "Service ID: %s\n", logentries.ServiceID) - fmt.Fprintf(out, "Version: %d\n", logentries.ServiceVersion) - fmt.Fprintf(out, "Name: %s\n", logentries.Name) - fmt.Fprintf(out, "Port: %d\n", logentries.Port) - fmt.Fprintf(out, "Use TLS: %t\n", logentries.UseTLS) - fmt.Fprintf(out, "Token: %s\n", logentries.Token) - fmt.Fprintf(out, "Format: %s\n", logentries.Format) - fmt.Fprintf(out, "Format version: %d\n", logentries.FormatVersion) - fmt.Fprintf(out, "Response condition: %s\n", logentries.ResponseCondition) - fmt.Fprintf(out, "Placement: %s\n", logentries.Placement) - - return nil -} diff --git a/pkg/logging/logentries/doc.go b/pkg/logging/logentries/doc.go deleted file mode 100644 index 4f768cff3..000000000 --- a/pkg/logging/logentries/doc.go +++ /dev/null @@ -1,3 +0,0 @@ -// Package logentries contains commands to inspect and manipulate Fastly service Logentries -// logging endpoints. -package logentries diff --git a/pkg/logging/logentries/list.go b/pkg/logging/logentries/list.go deleted file mode 100644 index ad517acfc..000000000 --- a/pkg/logging/logentries/list.go +++ /dev/null @@ -1,75 +0,0 @@ -package logentries - -import ( - "fmt" - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// ListCommand calls the Fastly API to list Logentries logging endpoints. -type ListCommand struct { - common.Base - manifest manifest.Data - Input fastly.ListLogentriesInput -} - -// NewListCommand returns a usable command registered under the parent. -func NewListCommand(parent common.Registerer, globals *config.Data) *ListCommand { - var c ListCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("list", "List Logentries endpoints on a Fastly service version") - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Input.ServiceVersion) - return &c -} - -// Exec invokes the application logic for the command. -func (c *ListCommand) Exec(in io.Reader, out io.Writer) error { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - c.Input.ServiceID = serviceID - - logentriess, err := c.Globals.Client.ListLogentries(&c.Input) - if err != nil { - return err - } - - if !c.Globals.Verbose() { - tw := text.NewTable(out) - tw.AddHeader("SERVICE", "VERSION", "NAME") - for _, logentries := range logentriess { - tw.AddLine(logentries.ServiceID, logentries.ServiceVersion, logentries.Name) - } - tw.Print() - return nil - } - - fmt.Fprintf(out, "Service ID: %s\n", c.Input.ServiceID) - fmt.Fprintf(out, "Version: %d\n", c.Input.ServiceVersion) - for i, logentries := range logentriess { - fmt.Fprintf(out, "\tLogentries %d/%d\n", i+1, len(logentriess)) - fmt.Fprintf(out, "\t\tService ID: %s\n", logentries.ServiceID) - fmt.Fprintf(out, "\t\tVersion: %d\n", logentries.ServiceVersion) - fmt.Fprintf(out, "\t\tName: %s\n", logentries.Name) - fmt.Fprintf(out, "\t\tPort: %d\n", logentries.Port) - fmt.Fprintf(out, "\t\tUse TLS: %t\n", logentries.UseTLS) - fmt.Fprintf(out, "\t\tToken: %s\n", logentries.Token) - fmt.Fprintf(out, "\t\tFormat: %s\n", logentries.Format) - fmt.Fprintf(out, "\t\tFormat version: %d\n", logentries.FormatVersion) - fmt.Fprintf(out, "\t\tResponse condition: %s\n", logentries.ResponseCondition) - fmt.Fprintf(out, "\t\tPlacement: %s\n", logentries.Placement) - } - fmt.Fprintln(out) - - return nil -} diff --git a/pkg/logging/logentries/logentries_integration_test.go b/pkg/logging/logentries/logentries_integration_test.go deleted file mode 100644 index 0b05fa5ed..000000000 --- a/pkg/logging/logentries/logentries_integration_test.go +++ /dev/null @@ -1,375 +0,0 @@ -package logentries_test - -import ( - "bytes" - "errors" - "io" - "net/http" - "strings" - "testing" - - "github.com/fastly/cli/pkg/app" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/mock" - "github.com/fastly/cli/pkg/testutil" - "github.com/fastly/cli/pkg/update" - "github.com/fastly/go-fastly/v3/fastly" -) - -func TestLogentriesCreate(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "logentries", "create", "--service-id", "123", "--version", "1", "--name", "log", "--port", "20000"}, - api: mock.API{CreateLogentriesFn: createLogentriesOK}, - wantOutput: "Created Logentries logging endpoint log (service 123 version 1)", - }, - { - args: []string{"logging", "logentries", "create", "--service-id", "123", "--version", "1", "--name", "log", "--port", "20000"}, - api: mock.API{CreateLogentriesFn: createLogentriesError}, - wantError: errTest.Error(), - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertStringContains(t, out.String(), testcase.wantOutput) - }) - } -} - -func TestLogentriesList(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "logentries", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListLogentriesFn: listLogentriesOK}, - wantOutput: listLogentriesShortOutput, - }, - { - args: []string{"logging", "logentries", "list", "--service-id", "123", "--version", "1", "--verbose"}, - api: mock.API{ListLogentriesFn: listLogentriesOK}, - wantOutput: listLogentriesVerboseOutput, - }, - { - args: []string{"logging", "logentries", "list", "--service-id", "123", "--version", "1", "-v"}, - api: mock.API{ListLogentriesFn: listLogentriesOK}, - wantOutput: listLogentriesVerboseOutput, - }, - { - args: []string{"logging", "logentries", "--verbose", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListLogentriesFn: listLogentriesOK}, - wantOutput: listLogentriesVerboseOutput, - }, - { - args: []string{"logging", "-v", "logentries", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListLogentriesFn: listLogentriesOK}, - wantOutput: listLogentriesVerboseOutput, - }, - { - args: []string{"logging", "logentries", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListLogentriesFn: listLogentriesError}, - wantError: errTest.Error(), - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertString(t, testcase.wantOutput, out.String()) - }) - } -} - -func TestLogentriesDescribe(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "logentries", "describe", "--service-id", "123", "--version", "1"}, - wantError: "error parsing arguments: required flag --name not provided", - }, - { - args: []string{"logging", "logentries", "describe", "--service-id", "123", "--version", "1", "--name", "logs"}, - api: mock.API{GetLogentriesFn: getLogentriesError}, - wantError: errTest.Error(), - }, - { - args: []string{"logging", "logentries", "describe", "--service-id", "123", "--version", "1", "--name", "logs"}, - api: mock.API{GetLogentriesFn: getLogentriesOK}, - wantOutput: describeLogentriesOutput, - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertString(t, testcase.wantOutput, out.String()) - }) - } -} - -func TestLogentriesUpdate(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "logentries", "update", "--service-id", "123", "--version", "1", "--new-name", "log"}, - wantError: "error parsing arguments: required flag --name not provided", - }, - { - args: []string{"logging", "logentries", "update", "--service-id", "123", "--version", "1", "--name", "logs", "--new-name", "log"}, - api: mock.API{UpdateLogentriesFn: updateLogentriesError}, - wantError: errTest.Error(), - }, - { - args: []string{"logging", "logentries", "update", "--service-id", "123", "--version", "1", "--name", "logs", "--new-name", "log"}, - api: mock.API{UpdateLogentriesFn: updateLogentriesOK}, - wantOutput: "Updated Logentries logging endpoint log (service 123 version 1)", - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertStringContains(t, out.String(), testcase.wantOutput) - }) - } -} - -func TestLogentriesDelete(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "logentries", "delete", "--service-id", "123", "--version", "1"}, - wantError: "error parsing arguments: required flag --name not provided", - }, - { - args: []string{"logging", "logentries", "delete", "--service-id", "123", "--version", "1", "--name", "logs"}, - api: mock.API{DeleteLogentriesFn: deleteLogentriesError}, - wantError: errTest.Error(), - }, - { - args: []string{"logging", "logentries", "delete", "--service-id", "123", "--version", "1", "--name", "logs"}, - api: mock.API{DeleteLogentriesFn: deleteLogentriesOK}, - wantOutput: "Deleted Logentries logging endpoint logs (service 123 version 1)", - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertStringContains(t, out.String(), testcase.wantOutput) - }) - } -} - -var errTest = errors.New("fixture error") - -func createLogentriesOK(i *fastly.CreateLogentriesInput) (*fastly.Logentries, error) { - return &fastly.Logentries{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: i.Name, - }, nil -} - -func createLogentriesError(i *fastly.CreateLogentriesInput) (*fastly.Logentries, error) { - return nil, errTest -} - -func listLogentriesOK(i *fastly.ListLogentriesInput) ([]*fastly.Logentries, error) { - return []*fastly.Logentries{ - { - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "logs", - Port: 20000, - UseTLS: true, - Token: "tkn", - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - ResponseCondition: "Prevent default logging", - Placement: "none", - }, - { - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "analytics", - Port: 20001, - UseTLS: false, - Token: "tkn1", - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - ResponseCondition: "Prevent default logging", - Placement: "none", - }, - }, nil -} - -func listLogentriesError(i *fastly.ListLogentriesInput) ([]*fastly.Logentries, error) { - return nil, errTest -} - -var listLogentriesShortOutput = strings.TrimSpace(` -SERVICE VERSION NAME -123 1 logs -123 1 analytics -`) + "\n" - -var listLogentriesVerboseOutput = strings.TrimSpace(` -Fastly API token not provided -Fastly API endpoint: https://api.fastly.com -Service ID: 123 -Version: 1 - Logentries 1/2 - Service ID: 123 - Version: 1 - Name: logs - Port: 20000 - Use TLS: true - Token: tkn - Format: %h %l %u %t "%r" %>s %b - Format version: 2 - Response condition: Prevent default logging - Placement: none - Logentries 2/2 - Service ID: 123 - Version: 1 - Name: analytics - Port: 20001 - Use TLS: false - Token: tkn1 - Format: %h %l %u %t "%r" %>s %b - Format version: 2 - Response condition: Prevent default logging - Placement: none -`) + "\n\n" - -func getLogentriesOK(i *fastly.GetLogentriesInput) (*fastly.Logentries, error) { - return &fastly.Logentries{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "logs", - Port: 20000, - UseTLS: true, - Token: "tkn", - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - ResponseCondition: "Prevent default logging", - Placement: "none", - }, nil -} - -func getLogentriesError(i *fastly.GetLogentriesInput) (*fastly.Logentries, error) { - return nil, errTest -} - -var describeLogentriesOutput = strings.TrimSpace(` -Service ID: 123 -Version: 1 -Name: logs -Port: 20000 -Use TLS: true -Token: tkn -Format: %h %l %u %t "%r" %>s %b -Format version: 2 -Response condition: Prevent default logging -Placement: none -`) + "\n" - -func updateLogentriesOK(i *fastly.UpdateLogentriesInput) (*fastly.Logentries, error) { - return &fastly.Logentries{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "log", - Port: 20000, - UseTLS: true, - Token: "tkn", - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - ResponseCondition: "Prevent default logging", - Placement: "none", - }, nil -} - -func updateLogentriesError(i *fastly.UpdateLogentriesInput) (*fastly.Logentries, error) { - return nil, errTest -} - -func deleteLogentriesOK(i *fastly.DeleteLogentriesInput) error { - return nil -} - -func deleteLogentriesError(i *fastly.DeleteLogentriesInput) error { - return errTest -} diff --git a/pkg/logging/logentries/logentries_test.go b/pkg/logging/logentries/logentries_test.go deleted file mode 100644 index ea2fa7fc3..000000000 --- a/pkg/logging/logentries/logentries_test.go +++ /dev/null @@ -1,189 +0,0 @@ -package logentries - -import ( - "testing" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/mock" - "github.com/fastly/cli/pkg/testutil" - "github.com/fastly/go-fastly/v3/fastly" -) - -func TestCreateLogentriesInput(t *testing.T) { - for _, testcase := range []struct { - name string - cmd *CreateCommand - want *fastly.CreateLogentriesInput - wantError string - }{ - { - name: "required values set flag serviceID", - cmd: createCommandRequired(), - want: &fastly.CreateLogentriesInput{ - ServiceID: "123", - ServiceVersion: 2, - Name: "log", - }, - }, - { - name: "all values set flag serviceID", - cmd: createCommandOK(), - want: &fastly.CreateLogentriesInput{ - ServiceID: "123", - ServiceVersion: 2, - Name: "log", - Port: 22, - UseTLS: fastly.Compatibool(true), - Token: "tkn", - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - ResponseCondition: "Prevent default logging", - Placement: "none", - }, - }, - { - name: "error missing serviceID", - cmd: createCommandMissingServiceID(), - want: nil, - wantError: errors.ErrNoServiceID.Error(), - }, - } { - t.Run(testcase.name, func(t *testing.T) { - have, err := testcase.cmd.createInput() - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertEqual(t, testcase.want, have) - }) - } -} - -func TestUpdateLogentriesInput(t *testing.T) { - for _, testcase := range []struct { - name string - cmd *UpdateCommand - api mock.API - want *fastly.UpdateLogentriesInput - wantError string - }{ - { - name: "no updates", - cmd: updateCommandNoUpdates(), - api: mock.API{GetLogentriesFn: getLogentriesOK}, - want: &fastly.UpdateLogentriesInput{ - ServiceID: "123", - ServiceVersion: 2, - Name: "log", - }, - }, - { - name: "all values set flag serviceID", - cmd: updateCommandAll(), - api: mock.API{GetLogentriesFn: getLogentriesOK}, - want: &fastly.UpdateLogentriesInput{ - ServiceID: "123", - ServiceVersion: 2, - Name: "log", - NewName: fastly.String("new1"), - Port: fastly.Uint(23), - UseTLS: fastly.CBool(true), - Token: fastly.String("new2"), - Format: fastly.String("new3"), - FormatVersion: fastly.Uint(3), - ResponseCondition: fastly.String("new4"), - Placement: fastly.String("new5"), - }, - }, - { - name: "error missing serviceID", - cmd: updateCommandMissingServiceID(), - want: nil, - wantError: errors.ErrNoServiceID.Error(), - }, - } { - t.Run(testcase.name, func(t *testing.T) { - testcase.cmd.Base.Globals.Client = testcase.api - - have, err := testcase.cmd.createInput() - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertEqual(t, testcase.want, have) - }) - } -} - -func createCommandOK() *CreateCommand { - return &CreateCommand{ - manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, - EndpointName: "log", - Version: 2, - Port: common.OptionalUint{Optional: common.Optional{WasSet: true}, Value: 22}, - UseTLS: common.OptionalBool{Optional: common.Optional{WasSet: true}, Value: true}, - Token: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "tkn"}, - Format: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: `%h %l %u %t "%r" %>s %b`}, - FormatVersion: common.OptionalUint{Optional: common.Optional{WasSet: true}, Value: 2}, - ResponseCondition: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "Prevent default logging"}, - Placement: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "none"}, - } -} - -func createCommandRequired() *CreateCommand { - return &CreateCommand{ - manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, - EndpointName: "log", - Version: 2, - } -} - -func createCommandMissingServiceID() *CreateCommand { - res := createCommandOK() - res.manifest = manifest.Data{} - return res -} - -func updateCommandNoUpdates() *UpdateCommand { - return &UpdateCommand{ - Base: common.Base{Globals: &config.Data{Client: nil}}, - manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, - EndpointName: "log", - Version: 2, - } -} - -func updateCommandAll() *UpdateCommand { - return &UpdateCommand{ - Base: common.Base{Globals: &config.Data{Client: nil}}, - manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, - EndpointName: "log", - Version: 2, - Port: common.OptionalUint{Optional: common.Optional{WasSet: true}, Value: 23}, - UseTLS: common.OptionalBool{Optional: common.Optional{WasSet: true}, Value: true}, - NewName: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new1"}, - Token: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new2"}, - Format: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new3"}, - FormatVersion: common.OptionalUint{Optional: common.Optional{WasSet: true}, Value: 3}, - ResponseCondition: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new4"}, - Placement: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new5"}, - } -} - -func updateCommandMissingServiceID() *UpdateCommand { - res := updateCommandAll() - res.manifest = manifest.Data{} - return res -} - -func getLogentriesOK(i *fastly.GetLogentriesInput) (*fastly.Logentries, error) { - return &fastly.Logentries{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "logs", - Port: 22, - UseTLS: true, - Token: "tkn", - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - ResponseCondition: "Prevent default logging", - Placement: "none", - }, nil -} diff --git a/pkg/logging/logentries/root.go b/pkg/logging/logentries/root.go deleted file mode 100644 index 9aa68b2dd..000000000 --- a/pkg/logging/logentries/root.go +++ /dev/null @@ -1,28 +0,0 @@ -package logentries - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/config" -) - -// RootCommand is the parent command for all subcommands in this package. -// It should be installed under the primary root command. -type RootCommand struct { - common.Base - // no flags -} - -// NewRootCommand returns a new command registered in the parent. -func NewRootCommand(parent common.Registerer, globals *config.Data) *RootCommand { - var c RootCommand - c.Globals = globals - c.CmdClause = parent.Command("logentries", "Manipulate Fastly service version Logentries logging endpoints") - return &c -} - -// Exec implements the command interface. -func (c *RootCommand) Exec(in io.Reader, out io.Writer) error { - panic("unreachable") -} diff --git a/pkg/logging/logentries/update.go b/pkg/logging/logentries/update.go deleted file mode 100644 index aaee6546c..000000000 --- a/pkg/logging/logentries/update.go +++ /dev/null @@ -1,122 +0,0 @@ -package logentries - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// UpdateCommand calls the Fastly API to update a Logentries logging endpoint. -type UpdateCommand struct { - common.Base - manifest manifest.Data - - // required - EndpointName string // Can't shadow common.Base method Name(). - Version int - - // optional - NewName common.OptionalString - Port common.OptionalUint - UseTLS common.OptionalBool - Token common.OptionalString - Format common.OptionalString - FormatVersion common.OptionalUint - ResponseCondition common.OptionalString - Placement common.OptionalString -} - -// NewUpdateCommand returns a usable command registered under the parent. -func NewUpdateCommand(parent common.Registerer, globals *config.Data) *UpdateCommand { - var c UpdateCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - - c.CmdClause = parent.Command("update", "Update a Logentries logging endpoint on a Fastly service version") - - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Version) - c.CmdClause.Flag("name", "The name of the Logentries logging object").Short('n').Required().StringVar(&c.EndpointName) - - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("new-name", "New name of the Logentries logging object").Action(c.NewName.Set).StringVar(&c.NewName.Value) - c.CmdClause.Flag("port", "The port number").Action(c.Port.Set).UintVar(&c.Port.Value) - c.CmdClause.Flag("use-tls", "Whether to use TLS for secure logging. Can be either true or false").Action(c.UseTLS.Set).BoolVar(&c.UseTLS.Value) - c.CmdClause.Flag("auth-token", "Use token based authentication (https://logentries.com/doc/input-token/)").Action(c.Token.Set).StringVar(&c.Token.Value) - c.CmdClause.Flag("format", "Apache style log formatting").Action(c.Format.Set).StringVar(&c.Format.Value) - c.CmdClause.Flag("format-version", "The version of the custom logging format used for the configured endpoint. Can be either 2 (the default, version 2 log format) or 1 (the version 1 log format). The logging call gets placed by default in vcl_log if format_version is set to 2 and in vcl_deliver if format_version is set to 1").Action(c.FormatVersion.Set).UintVar(&c.FormatVersion.Value) - c.CmdClause.Flag("response-condition", "The name of an existing condition in the configured endpoint, or leave blank to always execute").Action(c.ResponseCondition.Set).StringVar(&c.ResponseCondition.Value) - c.CmdClause.Flag("placement", "Where in the generated VCL the logging call should be placed, overriding any format_version default. Can be none or waf_debug. This field is not required and has no default value").Action(c.Placement.Set).StringVar(&c.Placement.Value) - - return &c -} - -// createInput transforms values parsed from CLI flags into an object to be used by the API client library. -func (c *UpdateCommand) createInput() (*fastly.UpdateLogentriesInput, error) { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return nil, errors.ErrNoServiceID - } - - input := fastly.UpdateLogentriesInput{ - ServiceID: serviceID, - ServiceVersion: c.Version, - Name: c.EndpointName, - } - - // Set new values if set by user. - if c.NewName.WasSet { - input.NewName = fastly.String(c.NewName.Value) - } - - if c.Port.WasSet { - input.Port = fastly.Uint(c.Port.Value) - } - - if c.UseTLS.WasSet { - input.UseTLS = fastly.CBool(c.UseTLS.Value) - } - - if c.Token.WasSet { - input.Token = fastly.String(c.Token.Value) - } - - if c.Format.WasSet { - input.Format = fastly.String(c.Format.Value) - } - - if c.FormatVersion.WasSet { - input.FormatVersion = fastly.Uint(c.FormatVersion.Value) - } - - if c.ResponseCondition.WasSet { - input.ResponseCondition = fastly.String(c.ResponseCondition.Value) - } - - if c.Placement.WasSet { - input.Placement = fastly.String(c.Placement.Value) - } - - return &input, nil -} - -// Exec invokes the application logic for the command. -func (c *UpdateCommand) Exec(in io.Reader, out io.Writer) error { - input, err := c.createInput() - if err != nil { - return err - } - - logentries, err := c.Globals.Client.UpdateLogentries(input) - if err != nil { - return err - } - - text.Success(out, "Updated Logentries logging endpoint %s (service %s version %d)", logentries.Name, logentries.ServiceID, logentries.ServiceVersion) - return nil -} diff --git a/pkg/logging/loggly/create.go b/pkg/logging/loggly/create.go deleted file mode 100644 index c65988ac6..000000000 --- a/pkg/logging/loggly/create.go +++ /dev/null @@ -1,101 +0,0 @@ -package loggly - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// CreateCommand calls the Fastly API to create a Loggly logging endpoint. -type CreateCommand struct { - common.Base - manifest manifest.Data - - // required - EndpointName string // Can't shadow common.Base method Name(). - Token string - Version int - - // optional - Format common.OptionalString - FormatVersion common.OptionalUint - ResponseCondition common.OptionalString - Placement common.OptionalString -} - -// NewCreateCommand returns a usable command registered under the parent. -func NewCreateCommand(parent common.Registerer, globals *config.Data) *CreateCommand { - var c CreateCommand - - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("create", "Create a Loggly logging endpoint on a Fastly service version").Alias("add") - - c.CmdClause.Flag("name", "The name of the Loggly logging object. Used as a primary key for API access").Short('n').Required().StringVar(&c.EndpointName) - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Version) - - c.CmdClause.Flag("auth-token", "The token to use for authentication (https://www.loggly.com/docs/customer-token-authentication-token/)").Required().StringVar(&c.Token) - - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("format", "Apache style log formatting").Action(c.Format.Set).StringVar(&c.Format.Value) - c.CmdClause.Flag("format-version", "The version of the custom logging format used for the configured endpoint. Can be either 2 (default) or 1").Action(c.FormatVersion.Set).UintVar(&c.FormatVersion.Value) - c.CmdClause.Flag("response-condition", "The name of an existing condition in the configured endpoint, or leave blank to always execute").Action(c.ResponseCondition.Set).StringVar(&c.ResponseCondition.Value) - c.CmdClause.Flag("placement", "Where in the generated VCL the logging call should be placed, overriding any format_version default. Can be none or waf_debug").Action(c.Placement.Set).StringVar(&c.Placement.Value) - - return &c -} - -// createInput transforms values parsed from CLI flags into an object to be used by the API client library. -func (c *CreateCommand) createInput() (*fastly.CreateLogglyInput, error) { - var input fastly.CreateLogglyInput - - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return nil, errors.ErrNoServiceID - } - - input.ServiceID = serviceID - input.ServiceVersion = c.Version - input.Name = c.EndpointName - input.Token = c.Token - - if c.Format.WasSet { - input.Format = c.Format.Value - } - - if c.FormatVersion.WasSet { - input.FormatVersion = c.FormatVersion.Value - } - - if c.ResponseCondition.WasSet { - input.ResponseCondition = c.ResponseCondition.Value - } - - if c.Placement.WasSet { - input.Placement = c.Placement.Value - } - - return &input, nil -} - -// Exec invokes the application logic for the command. -func (c *CreateCommand) Exec(in io.Reader, out io.Writer) error { - input, err := c.createInput() - if err != nil { - return err - } - - d, err := c.Globals.Client.CreateLoggly(input) - if err != nil { - return err - } - - text.Success(out, "Created Loggly logging endpoint %s (service %s version %d)", d.Name, d.ServiceID, d.ServiceVersion) - return nil -} diff --git a/pkg/logging/loggly/delete.go b/pkg/logging/loggly/delete.go deleted file mode 100644 index e929d53b4..000000000 --- a/pkg/logging/loggly/delete.go +++ /dev/null @@ -1,51 +0,0 @@ -package loggly - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// DeleteCommand calls the Fastly API to delete a Loggly logging endpoint. -type DeleteCommand struct { - common.Base - manifest manifest.Data - Input fastly.DeleteLogglyInput -} - -// NewDeleteCommand returns a usable command registered under the parent. -func NewDeleteCommand(parent common.Registerer, globals *config.Data) *DeleteCommand { - var c DeleteCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("delete", "Delete a Loggly logging endpoint on a Fastly service version").Alias("remove") - - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Input.ServiceVersion) - c.CmdClause.Flag("name", "The name of the Loggly logging object").Short('n').Required().StringVar(&c.Input.Name) - - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - - return &c -} - -// Exec invokes the application logic for the command. -func (c *DeleteCommand) Exec(in io.Reader, out io.Writer) error { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - c.Input.ServiceID = serviceID - - if err := c.Globals.Client.DeleteLoggly(&c.Input); err != nil { - return err - } - - text.Success(out, "Deleted Loggly logging endpoint %s (service %s version %d)", c.Input.Name, c.Input.ServiceID, c.Input.ServiceVersion) - return nil -} diff --git a/pkg/logging/loggly/describe.go b/pkg/logging/loggly/describe.go deleted file mode 100644 index 92b477e65..000000000 --- a/pkg/logging/loggly/describe.go +++ /dev/null @@ -1,57 +0,0 @@ -package loggly - -import ( - "fmt" - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/go-fastly/v3/fastly" -) - -// DescribeCommand calls the Fastly API to describe a Loggly logging endpoint. -type DescribeCommand struct { - common.Base - manifest manifest.Data - Input fastly.GetLogglyInput -} - -// NewDescribeCommand returns a usable command registered under the parent. -func NewDescribeCommand(parent common.Registerer, globals *config.Data) *DescribeCommand { - var c DescribeCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("describe", "Show detailed information about a Loggly logging endpoint on a Fastly service version").Alias("get") - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Input.ServiceVersion) - c.CmdClause.Flag("name", "The name of the Loggly logging object").Short('n').Required().StringVar(&c.Input.Name) - return &c -} - -// Exec invokes the application logic for the command. -func (c *DescribeCommand) Exec(in io.Reader, out io.Writer) error { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - c.Input.ServiceID = serviceID - - loggly, err := c.Globals.Client.GetLoggly(&c.Input) - if err != nil { - return err - } - - fmt.Fprintf(out, "Service ID: %s\n", loggly.ServiceID) - fmt.Fprintf(out, "Version: %d\n", loggly.ServiceVersion) - fmt.Fprintf(out, "Name: %s\n", loggly.Name) - fmt.Fprintf(out, "Token: %s\n", loggly.Token) - fmt.Fprintf(out, "Format: %s\n", loggly.Format) - fmt.Fprintf(out, "Format version: %d\n", loggly.FormatVersion) - fmt.Fprintf(out, "Response condition: %s\n", loggly.ResponseCondition) - fmt.Fprintf(out, "Placement: %s\n", loggly.Placement) - - return nil -} diff --git a/pkg/logging/loggly/list.go b/pkg/logging/loggly/list.go deleted file mode 100644 index 23936c9c1..000000000 --- a/pkg/logging/loggly/list.go +++ /dev/null @@ -1,73 +0,0 @@ -package loggly - -import ( - "fmt" - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// ListCommand calls the Fastly API to list Loggly logging endpoints. -type ListCommand struct { - common.Base - manifest manifest.Data - Input fastly.ListLogglyInput -} - -// NewListCommand returns a usable command registered under the parent. -func NewListCommand(parent common.Registerer, globals *config.Data) *ListCommand { - var c ListCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("list", "List Loggly endpoints on a Fastly service version") - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Input.ServiceVersion) - return &c -} - -// Exec invokes the application logic for the command. -func (c *ListCommand) Exec(in io.Reader, out io.Writer) error { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - c.Input.ServiceID = serviceID - - logglys, err := c.Globals.Client.ListLoggly(&c.Input) - if err != nil { - return err - } - - if !c.Globals.Verbose() { - tw := text.NewTable(out) - tw.AddHeader("SERVICE", "VERSION", "NAME") - for _, loggly := range logglys { - tw.AddLine(loggly.ServiceID, loggly.ServiceVersion, loggly.Name) - } - tw.Print() - return nil - } - - fmt.Fprintf(out, "Service ID: %s\n", c.Input.ServiceID) - fmt.Fprintf(out, "Version: %d\n", c.Input.ServiceVersion) - for i, loggly := range logglys { - fmt.Fprintf(out, "\tLoggly %d/%d\n", i+1, len(logglys)) - fmt.Fprintf(out, "\t\tService ID: %s\n", loggly.ServiceID) - fmt.Fprintf(out, "\t\tVersion: %d\n", loggly.ServiceVersion) - fmt.Fprintf(out, "\t\tName: %s\n", loggly.Name) - fmt.Fprintf(out, "\t\tToken: %s\n", loggly.Token) - fmt.Fprintf(out, "\t\tFormat: %s\n", loggly.Format) - fmt.Fprintf(out, "\t\tFormat version: %d\n", loggly.FormatVersion) - fmt.Fprintf(out, "\t\tResponse condition: %s\n", loggly.ResponseCondition) - fmt.Fprintf(out, "\t\tPlacement: %s\n", loggly.Placement) - } - fmt.Fprintln(out) - - return nil -} diff --git a/pkg/logging/loggly/loggly_integration_test.go b/pkg/logging/loggly/loggly_integration_test.go deleted file mode 100644 index cfaf7083e..000000000 --- a/pkg/logging/loggly/loggly_integration_test.go +++ /dev/null @@ -1,369 +0,0 @@ -package loggly_test - -import ( - "bytes" - "errors" - "io" - "net/http" - "strings" - "testing" - - "github.com/fastly/cli/pkg/app" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/mock" - "github.com/fastly/cli/pkg/testutil" - "github.com/fastly/cli/pkg/update" - "github.com/fastly/go-fastly/v3/fastly" -) - -func TestLogglyCreate(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "loggly", "create", "--service-id", "123", "--version", "1", "--name", "log"}, - wantError: "error parsing arguments: required flag --auth-token not provided", - }, - { - args: []string{"logging", "loggly", "create", "--service-id", "123", "--version", "1", "--name", "log", "--auth-token", "abc"}, - api: mock.API{CreateLogglyFn: createLogglyOK}, - wantOutput: "Created Loggly logging endpoint log (service 123 version 1)", - }, - { - args: []string{"logging", "loggly", "create", "--service-id", "123", "--version", "1", "--name", "log", "--auth-token", "abc"}, - api: mock.API{CreateLogglyFn: createLogglyError}, - wantError: errTest.Error(), - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertStringContains(t, out.String(), testcase.wantOutput) - }) - } -} - -func TestLogglyList(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "loggly", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListLogglyFn: listLogglysOK}, - wantOutput: listLogglysShortOutput, - }, - { - args: []string{"logging", "loggly", "list", "--service-id", "123", "--version", "1", "--verbose"}, - api: mock.API{ListLogglyFn: listLogglysOK}, - wantOutput: listLogglysVerboseOutput, - }, - { - args: []string{"logging", "loggly", "list", "--service-id", "123", "--version", "1", "-v"}, - api: mock.API{ListLogglyFn: listLogglysOK}, - wantOutput: listLogglysVerboseOutput, - }, - { - args: []string{"logging", "loggly", "--verbose", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListLogglyFn: listLogglysOK}, - wantOutput: listLogglysVerboseOutput, - }, - { - args: []string{"logging", "-v", "loggly", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListLogglyFn: listLogglysOK}, - wantOutput: listLogglysVerboseOutput, - }, - { - args: []string{"logging", "loggly", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListLogglyFn: listLogglysError}, - wantError: errTest.Error(), - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertString(t, testcase.wantOutput, out.String()) - }) - } -} - -func TestLogglyDescribe(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "loggly", "describe", "--service-id", "123", "--version", "1"}, - wantError: "error parsing arguments: required flag --name not provided", - }, - { - args: []string{"logging", "loggly", "describe", "--service-id", "123", "--version", "1", "--name", "logs"}, - api: mock.API{GetLogglyFn: getLogglyError}, - wantError: errTest.Error(), - }, - { - args: []string{"logging", "loggly", "describe", "--service-id", "123", "--version", "1", "--name", "logs"}, - api: mock.API{GetLogglyFn: getLogglyOK}, - wantOutput: describeLogglyOutput, - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertString(t, testcase.wantOutput, out.String()) - }) - } -} - -func TestLogglyUpdate(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "loggly", "update", "--service-id", "123", "--version", "1", "--new-name", "log"}, - wantError: "error parsing arguments: required flag --name not provided", - }, - { - args: []string{"logging", "loggly", "update", "--service-id", "123", "--version", "1", "--name", "logs", "--new-name", "log"}, - api: mock.API{UpdateLogglyFn: updateLogglyError}, - wantError: errTest.Error(), - }, - { - args: []string{"logging", "loggly", "update", "--service-id", "123", "--version", "1", "--name", "logs", "--new-name", "log"}, - api: mock.API{UpdateLogglyFn: updateLogglyOK}, - wantOutput: "Updated Loggly logging endpoint log (service 123 version 1)", - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertStringContains(t, out.String(), testcase.wantOutput) - }) - } -} - -func TestLogglyDelete(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "loggly", "delete", "--service-id", "123", "--version", "1"}, - wantError: "error parsing arguments: required flag --name not provided", - }, - { - args: []string{"logging", "loggly", "delete", "--service-id", "123", "--version", "1", "--name", "logs"}, - api: mock.API{DeleteLogglyFn: deleteLogglyError}, - wantError: errTest.Error(), - }, - { - args: []string{"logging", "loggly", "delete", "--service-id", "123", "--version", "1", "--name", "logs"}, - api: mock.API{DeleteLogglyFn: deleteLogglyOK}, - wantOutput: "Deleted Loggly logging endpoint logs (service 123 version 1)", - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertStringContains(t, out.String(), testcase.wantOutput) - }) - } -} - -var errTest = errors.New("fixture error") - -func createLogglyOK(i *fastly.CreateLogglyInput) (*fastly.Loggly, error) { - s := fastly.Loggly{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - } - - if i.Name != "" { - s.Name = i.Name - } - - return &s, nil -} - -func createLogglyError(i *fastly.CreateLogglyInput) (*fastly.Loggly, error) { - return nil, errTest -} - -func listLogglysOK(i *fastly.ListLogglyInput) ([]*fastly.Loggly, error) { - return []*fastly.Loggly{ - { - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "logs", - Token: "abc", - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - ResponseCondition: "Prevent default logging", - Placement: "none", - }, - { - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "analytics", - Token: "abc", - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - ResponseCondition: "Prevent default logging", - Placement: "none", - }, - }, nil -} - -func listLogglysError(i *fastly.ListLogglyInput) ([]*fastly.Loggly, error) { - return nil, errTest -} - -var listLogglysShortOutput = strings.TrimSpace(` -SERVICE VERSION NAME -123 1 logs -123 1 analytics -`) + "\n" - -var listLogglysVerboseOutput = strings.TrimSpace(` -Fastly API token not provided -Fastly API endpoint: https://api.fastly.com -Service ID: 123 -Version: 1 - Loggly 1/2 - Service ID: 123 - Version: 1 - Name: logs - Token: abc - Format: %h %l %u %t "%r" %>s %b - Format version: 2 - Response condition: Prevent default logging - Placement: none - Loggly 2/2 - Service ID: 123 - Version: 1 - Name: analytics - Token: abc - Format: %h %l %u %t "%r" %>s %b - Format version: 2 - Response condition: Prevent default logging - Placement: none -`) + "\n\n" - -func getLogglyOK(i *fastly.GetLogglyInput) (*fastly.Loggly, error) { - return &fastly.Loggly{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "logs", - Token: "abc", - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - ResponseCondition: "Prevent default logging", - Placement: "none", - }, nil -} - -func getLogglyError(i *fastly.GetLogglyInput) (*fastly.Loggly, error) { - return nil, errTest -} - -var describeLogglyOutput = strings.TrimSpace(` -Service ID: 123 -Version: 1 -Name: logs -Token: abc -Format: %h %l %u %t "%r" %>s %b -Format version: 2 -Response condition: Prevent default logging -Placement: none -`) + "\n" - -func updateLogglyOK(i *fastly.UpdateLogglyInput) (*fastly.Loggly, error) { - return &fastly.Loggly{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "log", - Token: "abc", - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - ResponseCondition: "Prevent default logging", - }, nil -} - -func updateLogglyError(i *fastly.UpdateLogglyInput) (*fastly.Loggly, error) { - return nil, errTest -} - -func deleteLogglyOK(i *fastly.DeleteLogglyInput) error { - return nil -} - -func deleteLogglyError(i *fastly.DeleteLogglyInput) error { - return errTest -} diff --git a/pkg/logging/loggly/loggly_test.go b/pkg/logging/loggly/loggly_test.go deleted file mode 100644 index 8e8503e5f..000000000 --- a/pkg/logging/loggly/loggly_test.go +++ /dev/null @@ -1,181 +0,0 @@ -package loggly - -import ( - "testing" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/mock" - "github.com/fastly/cli/pkg/testutil" - "github.com/fastly/go-fastly/v3/fastly" -) - -func TestCreateLogglyInput(t *testing.T) { - for _, testcase := range []struct { - name string - cmd *CreateCommand - want *fastly.CreateLogglyInput - wantError string - }{ - { - name: "required values set flag serviceID", - cmd: createCommandRequired(), - want: &fastly.CreateLogglyInput{ - ServiceID: "123", - ServiceVersion: 2, - Name: "log", - Token: "tkn", - }, - }, - { - name: "all values set flag serviceID", - cmd: createCommandOK(), - want: &fastly.CreateLogglyInput{ - ServiceID: "123", - ServiceVersion: 2, - Name: "log", - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - Token: "tkn", - ResponseCondition: "Prevent default logging", - Placement: "none", - }, - }, - { - name: "error missing serviceID", - cmd: createCommandMissingServiceID(), - want: nil, - wantError: errors.ErrNoServiceID.Error(), - }, - } { - t.Run(testcase.name, func(t *testing.T) { - have, err := testcase.cmd.createInput() - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertEqual(t, testcase.want, have) - }) - } -} - -func TestUpdateLogglyInput(t *testing.T) { - for _, testcase := range []struct { - name string - cmd *UpdateCommand - api mock.API - want *fastly.UpdateLogglyInput - wantError string - }{ - { - name: "no updates", - cmd: updateCommandNoUpdates(), - api: mock.API{GetLogglyFn: getLogglyOK}, - want: &fastly.UpdateLogglyInput{ - ServiceID: "123", - ServiceVersion: 2, - Name: "log", - }, - }, - { - name: "all values set flag serviceID", - cmd: updateCommandAll(), - api: mock.API{GetLogglyFn: getLogglyOK}, - want: &fastly.UpdateLogglyInput{ - ServiceID: "123", - ServiceVersion: 2, - Name: "log", - NewName: fastly.String("new1"), - Format: fastly.String("new2"), - FormatVersion: fastly.Uint(3), - Token: fastly.String("new3"), - ResponseCondition: fastly.String("new4"), - Placement: fastly.String("new5"), - }, - }, - { - name: "error missing serviceID", - cmd: updateCommandMissingServiceID(), - want: nil, - wantError: errors.ErrNoServiceID.Error(), - }, - } { - t.Run(testcase.name, func(t *testing.T) { - testcase.cmd.Base.Globals.Client = testcase.api - - have, err := testcase.cmd.createInput() - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertEqual(t, testcase.want, have) - }) - } -} - -func createCommandOK() *CreateCommand { - return &CreateCommand{ - manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, - EndpointName: "log", - Token: "tkn", - Version: 2, - Format: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: `%h %l %u %t "%r" %>s %b`}, - FormatVersion: common.OptionalUint{Optional: common.Optional{WasSet: true}, Value: 2}, - ResponseCondition: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "Prevent default logging"}, - Placement: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "none"}, - } -} - -func createCommandRequired() *CreateCommand { - return &CreateCommand{ - manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, - EndpointName: "log", - Token: "tkn", - Version: 2, - } -} - -func createCommandMissingServiceID() *CreateCommand { - res := createCommandOK() - res.manifest = manifest.Data{} - return res -} - -func updateCommandNoUpdates() *UpdateCommand { - return &UpdateCommand{ - Base: common.Base{Globals: &config.Data{Client: nil}}, - manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, - EndpointName: "log", - Version: 2, - } -} - -func updateCommandAll() *UpdateCommand { - return &UpdateCommand{ - Base: common.Base{Globals: &config.Data{Client: nil}}, - manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, - EndpointName: "log", - Version: 2, - NewName: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new1"}, - Format: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new2"}, - FormatVersion: common.OptionalUint{Optional: common.Optional{WasSet: true}, Value: 3}, - Token: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new3"}, - ResponseCondition: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new4"}, - Placement: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new5"}, - } -} - -func updateCommandMissingServiceID() *UpdateCommand { - res := updateCommandAll() - res.manifest = manifest.Data{} - return res -} - -func getLogglyOK(i *fastly.GetLogglyInput) (*fastly.Loggly, error) { - return &fastly.Loggly{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "logs", - Token: "tkn", - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - ResponseCondition: "Prevent default logging", - Placement: "none", - }, nil -} diff --git a/pkg/logging/loggly/root.go b/pkg/logging/loggly/root.go deleted file mode 100644 index d7502dd98..000000000 --- a/pkg/logging/loggly/root.go +++ /dev/null @@ -1,28 +0,0 @@ -package loggly - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/config" -) - -// RootCommand is the parent command for all subcommands in this package. -// It should be installed under the primary root command. -type RootCommand struct { - common.Base - // no flags -} - -// NewRootCommand returns a new command registered in the parent. -func NewRootCommand(parent common.Registerer, globals *config.Data) *RootCommand { - var c RootCommand - c.Globals = globals - c.CmdClause = parent.Command("loggly", "Manipulate Fastly service version Loggly logging endpoints") - return &c -} - -// Exec implements the command interface. -func (c *RootCommand) Exec(in io.Reader, out io.Writer) error { - panic("unreachable") -} diff --git a/pkg/logging/loggly/update.go b/pkg/logging/loggly/update.go deleted file mode 100644 index 89f3aa07f..000000000 --- a/pkg/logging/loggly/update.go +++ /dev/null @@ -1,109 +0,0 @@ -package loggly - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// UpdateCommand calls the Fastly API to update a Loggly logging endpoint. -type UpdateCommand struct { - common.Base - manifest manifest.Data - - // required - EndpointName string // Can't shadow common.Base method Name(). - Version int - - // optional - NewName common.OptionalString - Format common.OptionalString - FormatVersion common.OptionalUint - Token common.OptionalString - ResponseCondition common.OptionalString - Placement common.OptionalString -} - -// NewUpdateCommand returns a usable command registered under the parent. -func NewUpdateCommand(parent common.Registerer, globals *config.Data) *UpdateCommand { - var c UpdateCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - - c.CmdClause = parent.Command("update", "Update a Loggly logging endpoint on a Fastly service version") - - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Version) - c.CmdClause.Flag("name", "The name of the Loggly logging object").Short('n').Required().StringVar(&c.EndpointName) - - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("new-name", "New name of the Loggly logging object").Action(c.NewName.Set).StringVar(&c.NewName.Value) - c.CmdClause.Flag("auth-token", "The token to use for authentication (https://www.loggly.com/docs/customer-token-authentication-token/)").Action(c.Token.Set).StringVar(&c.Token.Value) - c.CmdClause.Flag("format", "Apache style log formatting").Action(c.Format.Set).StringVar(&c.Format.Value) - c.CmdClause.Flag("format-version", "The version of the custom logging format used for the configured endpoint. Can be either 2 (default) or 1").Action(c.FormatVersion.Set).UintVar(&c.FormatVersion.Value) - c.CmdClause.Flag("response-condition", "The name of an existing condition in the configured endpoint, or leave blank to always execute").Action(c.ResponseCondition.Set).StringVar(&c.ResponseCondition.Value) - c.CmdClause.Flag("placement", "Where in the generated VCL the logging call should be placed, overriding any format_version default. Can be none or waf_debug").Action(c.Placement.Set).StringVar(&c.Placement.Value) - - return &c -} - -// createInput transforms values parsed from CLI flags into an object to be used by the API client library. -func (c *UpdateCommand) createInput() (*fastly.UpdateLogglyInput, error) { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return nil, errors.ErrNoServiceID - } - - input := fastly.UpdateLogglyInput{ - ServiceID: serviceID, - ServiceVersion: c.Version, - Name: c.EndpointName, - } - - if c.NewName.WasSet { - input.NewName = fastly.String(c.NewName.Value) - } - - if c.Format.WasSet { - input.Format = fastly.String(c.Format.Value) - } - - if c.FormatVersion.WasSet { - input.FormatVersion = fastly.Uint(c.FormatVersion.Value) - } - - if c.Token.WasSet { - input.Token = fastly.String(c.Token.Value) - } - - if c.ResponseCondition.WasSet { - input.ResponseCondition = fastly.String(c.ResponseCondition.Value) - } - - if c.Placement.WasSet { - input.Placement = fastly.String(c.Placement.Value) - } - - return &input, nil -} - -// Exec invokes the application logic for the command. -func (c *UpdateCommand) Exec(in io.Reader, out io.Writer) error { - input, err := c.createInput() - if err != nil { - return err - } - - loggly, err := c.Globals.Client.UpdateLoggly(input) - if err != nil { - return err - } - - text.Success(out, "Updated Loggly logging endpoint %s (service %s version %d)", loggly.Name, loggly.ServiceID, loggly.ServiceVersion) - return nil -} diff --git a/pkg/logging/logshuttle/create.go b/pkg/logging/logshuttle/create.go deleted file mode 100644 index b18385dc6..000000000 --- a/pkg/logging/logshuttle/create.go +++ /dev/null @@ -1,103 +0,0 @@ -package logshuttle - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// CreateCommand calls the Fastly API to create a Logshuttle logging endpoint. -type CreateCommand struct { - common.Base - manifest manifest.Data - - // required - EndpointName string // Can't shadow common.Base method Name(). - Version int - Token string - URL string - - // optional - Format common.OptionalString - FormatVersion common.OptionalUint - ResponseCondition common.OptionalString - Placement common.OptionalString -} - -// NewCreateCommand returns a usable command registered under the parent. -func NewCreateCommand(parent common.Registerer, globals *config.Data) *CreateCommand { - var c CreateCommand - - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("create", "Create a Logshuttle logging endpoint on a Fastly service version").Alias("add") - - c.CmdClause.Flag("name", "The name of the Logshuttle logging object. Used as a primary key for API access").Short('n').Required().StringVar(&c.EndpointName) - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Version) - c.CmdClause.Flag("url", "Your Log Shuttle endpoint url").Required().StringVar(&c.URL) - c.CmdClause.Flag("auth-token", "The data authentication token associated with this endpoint").Required().StringVar(&c.Token) - - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("format", "Apache style log formatting").Action(c.Format.Set).StringVar(&c.Format.Value) - c.CmdClause.Flag("format-version", "The version of the custom logging format used for the configured endpoint. Can be either 2 (default) or 1").Action(c.FormatVersion.Set).UintVar(&c.FormatVersion.Value) - c.CmdClause.Flag("response-condition", "The name of an existing condition in the configured endpoint, or leave blank to always execute").Action(c.ResponseCondition.Set).StringVar(&c.ResponseCondition.Value) - c.CmdClause.Flag("placement", "Where in the generated VCL the logging call should be placed, overriding any format_version default. Can be none or waf_debug").Action(c.Placement.Set).StringVar(&c.Placement.Value) - - return &c -} - -// createInput transforms values parsed from CLI flags into an object to be used by the API client library. -func (c *CreateCommand) createInput() (*fastly.CreateLogshuttleInput, error) { - var input fastly.CreateLogshuttleInput - - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return nil, errors.ErrNoServiceID - } - - input.ServiceID = serviceID - input.ServiceVersion = c.Version - input.Name = c.EndpointName - input.Token = c.Token - input.URL = c.URL - - if c.Format.WasSet { - input.Format = c.Format.Value - } - - if c.FormatVersion.WasSet { - input.FormatVersion = c.FormatVersion.Value - } - - if c.ResponseCondition.WasSet { - input.ResponseCondition = c.ResponseCondition.Value - } - - if c.Placement.WasSet { - input.Placement = c.Placement.Value - } - - return &input, nil -} - -// Exec invokes the application logic for the command. -func (c *CreateCommand) Exec(in io.Reader, out io.Writer) error { - input, err := c.createInput() - if err != nil { - return err - } - - d, err := c.Globals.Client.CreateLogshuttle(input) - if err != nil { - return err - } - - text.Success(out, "Created Logshuttle logging endpoint %s (service %s version %d)", d.Name, d.ServiceID, d.ServiceVersion) - return nil -} diff --git a/pkg/logging/logshuttle/delete.go b/pkg/logging/logshuttle/delete.go deleted file mode 100644 index 0fec3722a..000000000 --- a/pkg/logging/logshuttle/delete.go +++ /dev/null @@ -1,51 +0,0 @@ -package logshuttle - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// DeleteCommand calls the Fastly API to delete a Logshuttle logging endpoint. -type DeleteCommand struct { - common.Base - manifest manifest.Data - Input fastly.DeleteLogshuttleInput -} - -// NewDeleteCommand returns a usable command registered under the parent. -func NewDeleteCommand(parent common.Registerer, globals *config.Data) *DeleteCommand { - var c DeleteCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("delete", "Delete a Logshuttle logging endpoint on a Fastly service version").Alias("remove") - - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Input.ServiceVersion) - c.CmdClause.Flag("name", "The name of the Logshuttle logging object").Short('n').Required().StringVar(&c.Input.Name) - - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - - return &c -} - -// Exec invokes the application logic for the command. -func (c *DeleteCommand) Exec(in io.Reader, out io.Writer) error { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - c.Input.ServiceID = serviceID - - if err := c.Globals.Client.DeleteLogshuttle(&c.Input); err != nil { - return err - } - - text.Success(out, "Deleted Logshuttle logging endpoint %s (service %s version %d)", c.Input.Name, c.Input.ServiceID, c.Input.ServiceVersion) - return nil -} diff --git a/pkg/logging/logshuttle/describe.go b/pkg/logging/logshuttle/describe.go deleted file mode 100644 index f641afb9f..000000000 --- a/pkg/logging/logshuttle/describe.go +++ /dev/null @@ -1,58 +0,0 @@ -package logshuttle - -import ( - "fmt" - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/go-fastly/v3/fastly" -) - -// DescribeCommand calls the Fastly API to describe a Logshuttle logging endpoint. -type DescribeCommand struct { - common.Base - manifest manifest.Data - Input fastly.GetLogshuttleInput -} - -// NewDescribeCommand returns a usable command registered under the parent. -func NewDescribeCommand(parent common.Registerer, globals *config.Data) *DescribeCommand { - var c DescribeCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("describe", "Show detailed information about a Logshuttle logging endpoint on a Fastly service version").Alias("get") - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Input.ServiceVersion) - c.CmdClause.Flag("name", "The name of the Logshuttle logging object").Short('n').Required().StringVar(&c.Input.Name) - return &c -} - -// Exec invokes the application logic for the command. -func (c *DescribeCommand) Exec(in io.Reader, out io.Writer) error { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - c.Input.ServiceID = serviceID - - logshuttle, err := c.Globals.Client.GetLogshuttle(&c.Input) - if err != nil { - return err - } - - fmt.Fprintf(out, "Service ID: %s\n", logshuttle.ServiceID) - fmt.Fprintf(out, "Version: %d\n", logshuttle.ServiceVersion) - fmt.Fprintf(out, "Name: %s\n", logshuttle.Name) - fmt.Fprintf(out, "URL: %s\n", logshuttle.URL) - fmt.Fprintf(out, "Token: %s\n", logshuttle.Token) - fmt.Fprintf(out, "Format: %s\n", logshuttle.Format) - fmt.Fprintf(out, "Format version: %d\n", logshuttle.FormatVersion) - fmt.Fprintf(out, "Response condition: %s\n", logshuttle.ResponseCondition) - fmt.Fprintf(out, "Placement: %s\n", logshuttle.Placement) - - return nil -} diff --git a/pkg/logging/logshuttle/list.go b/pkg/logging/logshuttle/list.go deleted file mode 100644 index 125fc1b10..000000000 --- a/pkg/logging/logshuttle/list.go +++ /dev/null @@ -1,74 +0,0 @@ -package logshuttle - -import ( - "fmt" - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// ListCommand calls the Fastly API to list Logshuttle logging endpoints. -type ListCommand struct { - common.Base - manifest manifest.Data - Input fastly.ListLogshuttlesInput -} - -// NewListCommand returns a usable command registered under the parent. -func NewListCommand(parent common.Registerer, globals *config.Data) *ListCommand { - var c ListCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("list", "List Logshuttle endpoints on a Fastly service version") - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Input.ServiceVersion) - return &c -} - -// Exec invokes the application logic for the command. -func (c *ListCommand) Exec(in io.Reader, out io.Writer) error { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - c.Input.ServiceID = serviceID - - logshuttles, err := c.Globals.Client.ListLogshuttles(&c.Input) - if err != nil { - return err - } - - if !c.Globals.Verbose() { - tw := text.NewTable(out) - tw.AddHeader("SERVICE", "VERSION", "NAME") - for _, logshuttle := range logshuttles { - tw.AddLine(logshuttle.ServiceID, logshuttle.ServiceVersion, logshuttle.Name) - } - tw.Print() - return nil - } - - fmt.Fprintf(out, "Service ID: %s\n", c.Input.ServiceID) - fmt.Fprintf(out, "Version: %d\n", c.Input.ServiceVersion) - for i, logshuttle := range logshuttles { - fmt.Fprintf(out, "\tLogshuttle %d/%d\n", i+1, len(logshuttles)) - fmt.Fprintf(out, "\t\tService ID: %s\n", logshuttle.ServiceID) - fmt.Fprintf(out, "\t\tVersion: %d\n", logshuttle.ServiceVersion) - fmt.Fprintf(out, "\t\tName: %s\n", logshuttle.Name) - fmt.Fprintf(out, "\t\tURL: %s\n", logshuttle.URL) - fmt.Fprintf(out, "\t\tToken: %s\n", logshuttle.Token) - fmt.Fprintf(out, "\t\tFormat: %s\n", logshuttle.Format) - fmt.Fprintf(out, "\t\tFormat version: %d\n", logshuttle.FormatVersion) - fmt.Fprintf(out, "\t\tResponse condition: %s\n", logshuttle.ResponseCondition) - fmt.Fprintf(out, "\t\tPlacement: %s\n", logshuttle.Placement) - } - fmt.Fprintln(out) - - return nil -} diff --git a/pkg/logging/logshuttle/logshuttle_integration_test.go b/pkg/logging/logshuttle/logshuttle_integration_test.go deleted file mode 100644 index a6c62eba8..000000000 --- a/pkg/logging/logshuttle/logshuttle_integration_test.go +++ /dev/null @@ -1,381 +0,0 @@ -package logshuttle_test - -import ( - "bytes" - "errors" - "io" - "net/http" - "strings" - "testing" - - "github.com/fastly/cli/pkg/app" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/mock" - "github.com/fastly/cli/pkg/testutil" - "github.com/fastly/cli/pkg/update" - "github.com/fastly/go-fastly/v3/fastly" -) - -func TestLogshuttleCreate(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "logshuttle", "create", "--service-id", "123", "--version", "1", "--name", "log", "--auth-token", "abc"}, - wantError: "error parsing arguments: required flag --url not provided", - }, - { - args: []string{"logging", "logshuttle", "create", "--service-id", "123", "--version", "1", "--name", "log", "--url", "example.com"}, - wantError: "error parsing arguments: required flag --auth-token not provided", - }, - { - args: []string{"logging", "logshuttle", "create", "--service-id", "123", "--version", "1", "--name", "log", "--url", "example.com", "--auth-token", "abc"}, - api: mock.API{CreateLogshuttleFn: createLogshuttleOK}, - wantOutput: "Created Logshuttle logging endpoint log (service 123 version 1)", - }, - { - args: []string{"logging", "logshuttle", "create", "--service-id", "123", "--version", "1", "--name", "log", "--url", "example.com", "--auth-token", "abc"}, - api: mock.API{CreateLogshuttleFn: createLogshuttleError}, - wantError: errTest.Error(), - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertStringContains(t, out.String(), testcase.wantOutput) - }) - } -} - -func TestLogshuttleList(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "logshuttle", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListLogshuttlesFn: listLogshuttlesOK}, - wantOutput: listLogshuttlesShortOutput, - }, - { - args: []string{"logging", "logshuttle", "list", "--service-id", "123", "--version", "1", "--verbose"}, - api: mock.API{ListLogshuttlesFn: listLogshuttlesOK}, - wantOutput: listLogshuttlesVerboseOutput, - }, - { - args: []string{"logging", "logshuttle", "list", "--service-id", "123", "--version", "1", "-v"}, - api: mock.API{ListLogshuttlesFn: listLogshuttlesOK}, - wantOutput: listLogshuttlesVerboseOutput, - }, - { - args: []string{"logging", "logshuttle", "--verbose", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListLogshuttlesFn: listLogshuttlesOK}, - wantOutput: listLogshuttlesVerboseOutput, - }, - { - args: []string{"logging", "-v", "logshuttle", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListLogshuttlesFn: listLogshuttlesOK}, - wantOutput: listLogshuttlesVerboseOutput, - }, - { - args: []string{"logging", "logshuttle", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListLogshuttlesFn: listLogshuttlesError}, - wantError: errTest.Error(), - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertString(t, testcase.wantOutput, out.String()) - }) - } -} - -func TestLogshuttleDescribe(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "logshuttle", "describe", "--service-id", "123", "--version", "1"}, - wantError: "error parsing arguments: required flag --name not provided", - }, - { - args: []string{"logging", "logshuttle", "describe", "--service-id", "123", "--version", "1", "--name", "logs"}, - api: mock.API{GetLogshuttleFn: getLogshuttleError}, - wantError: errTest.Error(), - }, - { - args: []string{"logging", "logshuttle", "describe", "--service-id", "123", "--version", "1", "--name", "logs"}, - api: mock.API{GetLogshuttleFn: getLogshuttleOK}, - wantOutput: describeLogshuttleOutput, - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertString(t, testcase.wantOutput, out.String()) - }) - } -} - -func TestLogshuttleUpdate(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "logshuttle", "update", "--service-id", "123", "--version", "1", "--new-name", "log"}, - wantError: "error parsing arguments: required flag --name not provided", - }, - { - args: []string{"logging", "logshuttle", "update", "--service-id", "123", "--version", "1", "--name", "logs", "--new-name", "log"}, - api: mock.API{UpdateLogshuttleFn: updateLogshuttleError}, - wantError: errTest.Error(), - }, - { - args: []string{"logging", "logshuttle", "update", "--service-id", "123", "--version", "1", "--name", "logs", "--new-name", "log"}, - api: mock.API{UpdateLogshuttleFn: updateLogshuttleOK}, - wantOutput: "Updated Logshuttle logging endpoint log (service 123 version 1)", - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertStringContains(t, out.String(), testcase.wantOutput) - }) - } -} - -func TestLogshuttleDelete(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "logshuttle", "delete", "--service-id", "123", "--version", "1"}, - wantError: "error parsing arguments: required flag --name not provided", - }, - { - args: []string{"logging", "logshuttle", "delete", "--service-id", "123", "--version", "1", "--name", "logs"}, - api: mock.API{DeleteLogshuttleFn: deleteLogshuttleError}, - wantError: errTest.Error(), - }, - { - args: []string{"logging", "logshuttle", "delete", "--service-id", "123", "--version", "1", "--name", "logs"}, - api: mock.API{DeleteLogshuttleFn: deleteLogshuttleOK}, - wantOutput: "Deleted Logshuttle logging endpoint logs (service 123 version 1)", - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertStringContains(t, out.String(), testcase.wantOutput) - }) - } -} - -var errTest = errors.New("fixture error") - -func createLogshuttleOK(i *fastly.CreateLogshuttleInput) (*fastly.Logshuttle, error) { - s := fastly.Logshuttle{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - } - - if i.Name != "" { - s.Name = i.Name - } - - return &s, nil -} - -func createLogshuttleError(i *fastly.CreateLogshuttleInput) (*fastly.Logshuttle, error) { - return nil, errTest -} - -func listLogshuttlesOK(i *fastly.ListLogshuttlesInput) ([]*fastly.Logshuttle, error) { - return []*fastly.Logshuttle{ - { - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "logs", - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - URL: "example.com", - Token: "abc", - ResponseCondition: "Prevent default logging", - Placement: "none", - }, - { - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "analytics", - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - URL: "example.com", - Token: "abc", - ResponseCondition: "Prevent default logging", - Placement: "none", - }, - }, nil -} - -func listLogshuttlesError(i *fastly.ListLogshuttlesInput) ([]*fastly.Logshuttle, error) { - return nil, errTest -} - -var listLogshuttlesShortOutput = strings.TrimSpace(` -SERVICE VERSION NAME -123 1 logs -123 1 analytics -`) + "\n" - -var listLogshuttlesVerboseOutput = strings.TrimSpace(` -Fastly API token not provided -Fastly API endpoint: https://api.fastly.com -Service ID: 123 -Version: 1 - Logshuttle 1/2 - Service ID: 123 - Version: 1 - Name: logs - URL: example.com - Token: abc - Format: %h %l %u %t "%r" %>s %b - Format version: 2 - Response condition: Prevent default logging - Placement: none - Logshuttle 2/2 - Service ID: 123 - Version: 1 - Name: analytics - URL: example.com - Token: abc - Format: %h %l %u %t "%r" %>s %b - Format version: 2 - Response condition: Prevent default logging - Placement: none -`) + "\n\n" - -func getLogshuttleOK(i *fastly.GetLogshuttleInput) (*fastly.Logshuttle, error) { - return &fastly.Logshuttle{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "logs", - URL: "example.com", - Token: "abc", - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - ResponseCondition: "Prevent default logging", - Placement: "none", - }, nil -} - -func getLogshuttleError(i *fastly.GetLogshuttleInput) (*fastly.Logshuttle, error) { - return nil, errTest -} - -var describeLogshuttleOutput = strings.TrimSpace(` -Service ID: 123 -Version: 1 -Name: logs -URL: example.com -Token: abc -Format: %h %l %u %t "%r" %>s %b -Format version: 2 -Response condition: Prevent default logging -Placement: none -`) + "\n" - -func updateLogshuttleOK(i *fastly.UpdateLogshuttleInput) (*fastly.Logshuttle, error) { - return &fastly.Logshuttle{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "log", - URL: "example.com", - Token: "abc", - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - ResponseCondition: "Prevent default logging", - Placement: "none", - }, nil -} - -func updateLogshuttleError(i *fastly.UpdateLogshuttleInput) (*fastly.Logshuttle, error) { - return nil, errTest -} - -func deleteLogshuttleOK(i *fastly.DeleteLogshuttleInput) error { - return nil -} - -func deleteLogshuttleError(i *fastly.DeleteLogshuttleInput) error { - return errTest -} diff --git a/pkg/logging/logshuttle/logshuttle_test.go b/pkg/logging/logshuttle/logshuttle_test.go deleted file mode 100644 index 36da62fa8..000000000 --- a/pkg/logging/logshuttle/logshuttle_test.go +++ /dev/null @@ -1,188 +0,0 @@ -package logshuttle - -import ( - "testing" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/mock" - "github.com/fastly/cli/pkg/testutil" - "github.com/fastly/go-fastly/v3/fastly" -) - -func TestCreateLogshuttleInput(t *testing.T) { - for _, testcase := range []struct { - name string - cmd *CreateCommand - want *fastly.CreateLogshuttleInput - wantError string - }{ - { - name: "required values set flag serviceID", - cmd: createCommandRequired(), - want: &fastly.CreateLogshuttleInput{ - ServiceID: "123", - ServiceVersion: 2, - Name: "log", - Token: "tkn", - URL: "example.com", - }, - }, - { - name: "all values set flag serviceID", - cmd: createCommandAll(), - want: &fastly.CreateLogshuttleInput{ - ServiceID: "123", - ServiceVersion: 2, - Name: "log", - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - URL: "example.com", - Token: "tkn", - ResponseCondition: "Prevent default logging", - Placement: "none", - }, - }, - { - name: "error missing serviceID", - cmd: createCommandMissingServiceID(), - want: nil, - wantError: errors.ErrNoServiceID.Error(), - }, - } { - t.Run(testcase.name, func(t *testing.T) { - have, err := testcase.cmd.createInput() - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertEqual(t, testcase.want, have) - }) - } -} - -func TestUpdateLogshuttleInput(t *testing.T) { - for _, testcase := range []struct { - name string - cmd *UpdateCommand - api mock.API - want *fastly.UpdateLogshuttleInput - wantError string - }{ - { - name: "no update", - cmd: updateCommandNoUpdate(), - api: mock.API{GetLogshuttleFn: getLogshuttleOK}, - want: &fastly.UpdateLogshuttleInput{ - ServiceID: "123", - ServiceVersion: 2, - Name: "log", - }, - }, - { - name: "all values set flag serviceID", - cmd: updateCommandAll(), - api: mock.API{GetLogshuttleFn: getLogshuttleOK}, - want: &fastly.UpdateLogshuttleInput{ - ServiceID: "123", - ServiceVersion: 2, - Name: "log", - NewName: fastly.String("new1"), - Format: fastly.String("new2"), - FormatVersion: fastly.Uint(3), - Token: fastly.String("new3"), - URL: fastly.String("new4"), - ResponseCondition: fastly.String("new5"), - Placement: fastly.String("new6"), - }, - }, - { - name: "error missing serviceID", - cmd: updateCommandMissingServiceID(), - want: nil, - wantError: errors.ErrNoServiceID.Error(), - }, - } { - t.Run(testcase.name, func(t *testing.T) { - testcase.cmd.Base.Globals.Client = testcase.api - - have, err := testcase.cmd.createInput() - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertEqual(t, testcase.want, have) - }) - } -} - -func createCommandRequired() *CreateCommand { - return &CreateCommand{ - manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, - EndpointName: "log", - Token: "tkn", - URL: "example.com", - Version: 2, - } -} - -func createCommandAll() *CreateCommand { - return &CreateCommand{ - manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, - EndpointName: "log", - Token: "tkn", - URL: "example.com", - Version: 2, - Format: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: `%h %l %u %t "%r" %>s %b`}, - FormatVersion: common.OptionalUint{Optional: common.Optional{WasSet: true}, Value: 2}, - ResponseCondition: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "Prevent default logging"}, - Placement: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "none"}, - } -} - -func createCommandMissingServiceID() *CreateCommand { - res := createCommandAll() - res.manifest = manifest.Data{} - return res -} - -func updateCommandNoUpdate() *UpdateCommand { - return &UpdateCommand{ - Base: common.Base{Globals: &config.Data{Client: nil}}, - manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, - EndpointName: "log", - Version: 2, - } -} - -func updateCommandAll() *UpdateCommand { - return &UpdateCommand{ - Base: common.Base{Globals: &config.Data{Client: nil}}, - manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, - EndpointName: "log", - Version: 2, - NewName: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new1"}, - Format: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new2"}, - FormatVersion: common.OptionalUint{Optional: common.Optional{WasSet: true}, Value: 3}, - Token: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new3"}, - URL: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new4"}, - ResponseCondition: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new5"}, - Placement: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new6"}, - } -} - -func updateCommandMissingServiceID() *UpdateCommand { - res := updateCommandAll() - res.manifest = manifest.Data{} - return res -} - -func getLogshuttleOK(i *fastly.GetLogshuttleInput) (*fastly.Logshuttle, error) { - return &fastly.Logshuttle{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "logs", - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - URL: "example.com", - Token: "tkn", - ResponseCondition: "Prevent default logging", - Placement: "none", - }, nil -} diff --git a/pkg/logging/logshuttle/root.go b/pkg/logging/logshuttle/root.go deleted file mode 100644 index 06940da98..000000000 --- a/pkg/logging/logshuttle/root.go +++ /dev/null @@ -1,28 +0,0 @@ -package logshuttle - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/config" -) - -// RootCommand is the parent command for all subcommands in this package. -// It should be installed under the primary root command. -type RootCommand struct { - common.Base - // no flags -} - -// NewRootCommand returns a new command registered in the parent. -func NewRootCommand(parent common.Registerer, globals *config.Data) *RootCommand { - var c RootCommand - c.Globals = globals - c.CmdClause = parent.Command("logshuttle", "Manipulate Fastly service version Logshuttle logging endpoints") - return &c -} - -// Exec implements the command interface. -func (c *RootCommand) Exec(in io.Reader, out io.Writer) error { - panic("unreachable") -} diff --git a/pkg/logging/logshuttle/update.go b/pkg/logging/logshuttle/update.go deleted file mode 100644 index a8eb14cfd..000000000 --- a/pkg/logging/logshuttle/update.go +++ /dev/null @@ -1,117 +0,0 @@ -package logshuttle - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// UpdateCommand calls the Fastly API to update a Logshuttle logging endpoint. -type UpdateCommand struct { - common.Base - manifest manifest.Data - - // required - EndpointName string // Can't shadow common.Base method Name(). - Version int - - // optional - NewName common.OptionalString - Format common.OptionalString - FormatVersion common.OptionalUint - Token common.OptionalString - URL common.OptionalString - ResponseCondition common.OptionalString - Placement common.OptionalString -} - -// NewUpdateCommand returns a usable command registered under the parent. -func NewUpdateCommand(parent common.Registerer, globals *config.Data) *UpdateCommand { - var c UpdateCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - - c.CmdClause = parent.Command("update", "Update a Logshuttle logging endpoint on a Fastly service version") - - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Version) - c.CmdClause.Flag("name", "The name of the Logshuttle logging object").Short('n').Required().StringVar(&c.EndpointName) - - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("new-name", "New name of the Logshuttle logging object").Action(c.NewName.Set).StringVar(&c.NewName.Value) - - c.CmdClause.Flag("format", "Apache style log formatting").Action(c.Format.Set).StringVar(&c.Format.Value) - c.CmdClause.Flag("format-version", "The version of the custom logging format used for the configured endpoint. Can be either 2 (default) or 1").Action(c.FormatVersion.Set).UintVar(&c.FormatVersion.Value) - c.CmdClause.Flag("url", "Your Log Shuttle endpoint url").Action(c.URL.Set).StringVar(&c.URL.Value) - c.CmdClause.Flag("auth-token", "The data authentication token associated with this endpoint").Action(c.Token.Set).StringVar(&c.Token.Value) - c.CmdClause.Flag("response-condition", "The name of an existing condition in the configured endpoint, or leave blank to always execute").Action(c.ResponseCondition.Set).StringVar(&c.ResponseCondition.Value) - c.CmdClause.Flag("placement", "Where in the generated VCL the logging call should be placed, overriding any format_version default. Can be none or waf_debug").Action(c.Placement.Set).StringVar(&c.Placement.Value) - - return &c -} - -// createInput transforms values parsed from CLI flags into an object to be used by the API client library. -func (c *UpdateCommand) createInput() (*fastly.UpdateLogshuttleInput, error) { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return nil, errors.ErrNoServiceID - } - - input := fastly.UpdateLogshuttleInput{ - ServiceID: serviceID, - ServiceVersion: c.Version, - Name: c.EndpointName, - } - - // Set new values if set by user. - if c.NewName.WasSet { - input.NewName = fastly.String(c.NewName.Value) - } - - if c.Format.WasSet { - input.Format = fastly.String(c.Format.Value) - } - - if c.FormatVersion.WasSet { - input.FormatVersion = fastly.Uint(c.FormatVersion.Value) - } - - if c.URL.WasSet { - input.URL = fastly.String(c.URL.Value) - } - - if c.Token.WasSet { - input.Token = fastly.String(c.Token.Value) - } - - if c.ResponseCondition.WasSet { - input.ResponseCondition = fastly.String(c.ResponseCondition.Value) - } - - if c.Placement.WasSet { - input.Placement = fastly.String(c.Placement.Value) - } - - return &input, nil -} - -// Exec invokes the application logic for the command. -func (c *UpdateCommand) Exec(in io.Reader, out io.Writer) error { - input, err := c.createInput() - if err != nil { - return err - } - - logshuttle, err := c.Globals.Client.UpdateLogshuttle(input) - if err != nil { - return err - } - - text.Success(out, "Updated Logshuttle logging endpoint %s (service %s version %d)", logshuttle.Name, logshuttle.ServiceID, logshuttle.ServiceVersion) - return nil -} diff --git a/pkg/logging/openstack/create.go b/pkg/logging/openstack/create.go deleted file mode 100644 index c4309ceea..000000000 --- a/pkg/logging/openstack/create.go +++ /dev/null @@ -1,157 +0,0 @@ -package openstack - -import ( - "fmt" - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// CreateCommand calls the Fastly API to create an OpenStack logging endpoint. -type CreateCommand struct { - common.Base - manifest manifest.Data - - // required - EndpointName string // Can't shadow common.Base method Name(). - Version int - BucketName string - AccessKey string - User string - URL string - - // optional - PublicKey common.OptionalString - Path common.OptionalString - Period common.OptionalUint - GzipLevel common.OptionalUint - MessageType common.OptionalString - Format common.OptionalString - FormatVersion common.OptionalUint - ResponseCondition common.OptionalString - TimestampFormat common.OptionalString - Placement common.OptionalString - CompressionCodec common.OptionalString -} - -// NewCreateCommand returns a usable command registered under the parent. -func NewCreateCommand(parent common.Registerer, globals *config.Data) *CreateCommand { - var c CreateCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("create", "Create an OpenStack logging endpoint on a Fastly service version").Alias("add") - - c.CmdClause.Flag("name", "The name of the OpenStack logging object. Used as a primary key for API access").Short('n').Required().StringVar(&c.EndpointName) - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Version) - c.CmdClause.Flag("bucket", "The name of your OpenStack container").Required().StringVar(&c.BucketName) - c.CmdClause.Flag("access-key", "Your OpenStack account access key").Required().StringVar(&c.AccessKey) - c.CmdClause.Flag("user", "The username for your OpenStack account").Required().StringVar(&c.User) - c.CmdClause.Flag("url", "Your OpenStack auth url").Required().StringVar(&c.URL) - - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("public-key", "A PGP public key that Fastly will use to encrypt your log files before writing them to disk").Action(c.PublicKey.Set).StringVar(&c.PublicKey.Value) - c.CmdClause.Flag("path", "The path to upload logs to").Action(c.Path.Set).StringVar(&c.Path.Value) - c.CmdClause.Flag("period", "How frequently log files are finalized so they can be available for reading (in seconds, default 3600)").Action(c.Period.Set).UintVar(&c.Period.Value) - c.CmdClause.Flag("gzip-level", "What level of GZIP encoding to have when dumping logs (default 0, no compression)").Action(c.GzipLevel.Set).UintVar(&c.GzipLevel.Value) - c.CmdClause.Flag("format", "Apache style log formatting").Action(c.Format.Set).StringVar(&c.Format.Value) - c.CmdClause.Flag("message-type", "How the message should be formatted. One of: classic (default), loggly, logplex or blank").Action(c.MessageType.Set).StringVar(&c.MessageType.Value) - c.CmdClause.Flag("format-version", "The version of the custom logging format used for the configured endpoint. Can be either 2 (default) or 1").Action(c.FormatVersion.Set).UintVar(&c.FormatVersion.Value) - c.CmdClause.Flag("response-condition", "The name of an existing condition in the configured endpoint, or leave blank to always execute").Action(c.ResponseCondition.Set).StringVar(&c.ResponseCondition.Value) - c.CmdClause.Flag("timestamp-format", `strftime specified timestamp formatting (default "%Y-%m-%dT%H:%M:%S.000")`).Action(c.TimestampFormat.Set).StringVar(&c.TimestampFormat.Value) - c.CmdClause.Flag("placement", "Where in the generated VCL the logging call should be placed, overriding any format_version default. Can be none or waf_debug").Action(c.Placement.Set).StringVar(&c.Placement.Value) - c.CmdClause.Flag("compression-codec", `The codec used for compression of your logs. Valid values are zstd, snappy, and gzip. If the specified codec is "gzip", gzip_level will default to 3. To specify a different level, leave compression_codec blank and explicitly set the level using gzip_level. Specifying both compression_codec and gzip_level in the same API request will result in an error.`).Action(c.CompressionCodec.Set).StringVar(&c.CompressionCodec.Value) - - return &c -} - -// createInput transforms values parsed from CLI flags into an object to be used by the API client library. -func (c *CreateCommand) createInput() (*fastly.CreateOpenstackInput, error) { - var input fastly.CreateOpenstackInput - - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return nil, errors.ErrNoServiceID - } - - input.ServiceID = serviceID - input.ServiceVersion = c.Version - input.Name = c.EndpointName - input.BucketName = c.BucketName - input.AccessKey = c.AccessKey - input.User = c.User - input.URL = c.URL - - // The following blocks enforces the mutual exclusivity of the - // CompressionCodec and GzipLevel flags. - if c.CompressionCodec.WasSet && c.GzipLevel.WasSet { - return nil, fmt.Errorf("error parsing arguments: the --compression-codec flag is mutually exclusive with the --gzip-level flag") - } - - if c.PublicKey.WasSet { - input.PublicKey = c.PublicKey.Value - } - - if c.Path.WasSet { - input.Path = c.Path.Value - } - - if c.Period.WasSet { - input.Period = c.Period.Value - } - - if c.GzipLevel.WasSet { - input.GzipLevel = c.GzipLevel.Value - } - - if c.Format.WasSet { - input.Format = c.Format.Value - } - - if c.FormatVersion.WasSet { - input.FormatVersion = c.FormatVersion.Value - } - - if c.ResponseCondition.WasSet { - input.ResponseCondition = c.ResponseCondition.Value - } - - if c.MessageType.WasSet { - input.MessageType = c.MessageType.Value - } - - if c.TimestampFormat.WasSet { - input.TimestampFormat = c.TimestampFormat.Value - } - - if c.Placement.WasSet { - input.Placement = c.Placement.Value - } - - if c.CompressionCodec.WasSet { - input.CompressionCodec = c.CompressionCodec.Value - } - - return &input, nil -} - -// Exec invokes the application logic for the command. -func (c *CreateCommand) Exec(in io.Reader, out io.Writer) error { - input, err := c.createInput() - if err != nil { - return err - } - - d, err := c.Globals.Client.CreateOpenstack(input) - if err != nil { - return err - } - - text.Success(out, "Created OpenStack logging endpoint %s (service %s version %d)", d.Name, d.ServiceID, d.ServiceVersion) - return nil -} diff --git a/pkg/logging/openstack/delete.go b/pkg/logging/openstack/delete.go deleted file mode 100644 index 371e05d62..000000000 --- a/pkg/logging/openstack/delete.go +++ /dev/null @@ -1,51 +0,0 @@ -package openstack - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// DeleteCommand calls the Fastly API to delete an OpenStack logging endpoint. -type DeleteCommand struct { - common.Base - manifest manifest.Data - Input fastly.DeleteOpenstackInput -} - -// NewDeleteCommand returns a usable command registered under the parent. -func NewDeleteCommand(parent common.Registerer, globals *config.Data) *DeleteCommand { - var c DeleteCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("delete", "Delete an OpenStack logging endpoint on a Fastly service version").Alias("remove") - - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Input.ServiceVersion) - c.CmdClause.Flag("name", "The name of the OpenStack logging object").Short('n').Required().StringVar(&c.Input.Name) - - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - - return &c -} - -// Exec invokes the application logic for the command. -func (c *DeleteCommand) Exec(in io.Reader, out io.Writer) error { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - c.Input.ServiceID = serviceID - - if err := c.Globals.Client.DeleteOpenstack(&c.Input); err != nil { - return err - } - - text.Success(out, "Deleted OpenStack logging endpoint %s (service %s version %d)", c.Input.Name, c.Input.ServiceID, c.Input.ServiceVersion) - return nil -} diff --git a/pkg/logging/openstack/describe.go b/pkg/logging/openstack/describe.go deleted file mode 100644 index 423649e23..000000000 --- a/pkg/logging/openstack/describe.go +++ /dev/null @@ -1,67 +0,0 @@ -package openstack - -import ( - "fmt" - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/go-fastly/v3/fastly" -) - -// DescribeCommand calls the Fastly API to describe an OpenStack logging endpoint. -type DescribeCommand struct { - common.Base - manifest manifest.Data - Input fastly.GetOpenstackInput -} - -// NewDescribeCommand returns a usable command registered under the parent. -func NewDescribeCommand(parent common.Registerer, globals *config.Data) *DescribeCommand { - var c DescribeCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("describe", "Show detailed information about an OpenStack logging endpoint on a Fastly service version").Alias("get") - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Input.ServiceVersion) - c.CmdClause.Flag("name", "The name of the OpenStack logging object").Short('n').Required().StringVar(&c.Input.Name) - return &c -} - -// Exec invokes the application logic for the command. -func (c *DescribeCommand) Exec(in io.Reader, out io.Writer) error { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - c.Input.ServiceID = serviceID - - openstack, err := c.Globals.Client.GetOpenstack(&c.Input) - if err != nil { - return err - } - - fmt.Fprintf(out, "Service ID: %s\n", openstack.ServiceID) - fmt.Fprintf(out, "Version: %d\n", openstack.ServiceVersion) - fmt.Fprintf(out, "Name: %s\n", openstack.Name) - fmt.Fprintf(out, "Bucket: %s\n", openstack.BucketName) - fmt.Fprintf(out, "Access key: %s\n", openstack.AccessKey) - fmt.Fprintf(out, "User: %s\n", openstack.User) - fmt.Fprintf(out, "URL: %s\n", openstack.URL) - fmt.Fprintf(out, "Path: %s\n", openstack.Path) - fmt.Fprintf(out, "Period: %d\n", openstack.Period) - fmt.Fprintf(out, "GZip level: %d\n", openstack.GzipLevel) - fmt.Fprintf(out, "Format: %s\n", openstack.Format) - fmt.Fprintf(out, "Format version: %d\n", openstack.FormatVersion) - fmt.Fprintf(out, "Response condition: %s\n", openstack.ResponseCondition) - fmt.Fprintf(out, "Message type: %s\n", openstack.MessageType) - fmt.Fprintf(out, "Timestamp format: %s\n", openstack.TimestampFormat) - fmt.Fprintf(out, "Placement: %s\n", openstack.Placement) - fmt.Fprintf(out, "Public key: %s\n", openstack.PublicKey) - fmt.Fprintf(out, "Compression codec: %s\n", openstack.CompressionCodec) - - return nil -} diff --git a/pkg/logging/openstack/list.go b/pkg/logging/openstack/list.go deleted file mode 100644 index 319910fe7..000000000 --- a/pkg/logging/openstack/list.go +++ /dev/null @@ -1,83 +0,0 @@ -package openstack - -import ( - "fmt" - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// ListCommand calls the Fastly API to list OpenStack logging endpoints. -type ListCommand struct { - common.Base - manifest manifest.Data - Input fastly.ListOpenstackInput -} - -// NewListCommand returns a usable command registered under the parent. -func NewListCommand(parent common.Registerer, globals *config.Data) *ListCommand { - var c ListCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("list", "List OpenStack logging endpoints on a Fastly service version") - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Input.ServiceVersion) - return &c -} - -// Exec invokes the application logic for the command. -func (c *ListCommand) Exec(in io.Reader, out io.Writer) error { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - c.Input.ServiceID = serviceID - - openstacks, err := c.Globals.Client.ListOpenstack(&c.Input) - if err != nil { - return err - } - - if !c.Globals.Verbose() { - tw := text.NewTable(out) - tw.AddHeader("SERVICE", "VERSION", "NAME") - for _, openstack := range openstacks { - tw.AddLine(openstack.ServiceID, openstack.ServiceVersion, openstack.Name) - } - tw.Print() - return nil - } - - fmt.Fprintf(out, "Service ID: %s\n", c.Input.ServiceID) - fmt.Fprintf(out, "Version: %d\n", c.Input.ServiceVersion) - for i, openstack := range openstacks { - fmt.Fprintf(out, "\tOpenstack %d/%d\n", i+1, len(openstacks)) - fmt.Fprintf(out, "\t\tService ID: %s\n", openstack.ServiceID) - fmt.Fprintf(out, "\t\tVersion: %d\n", openstack.ServiceVersion) - fmt.Fprintf(out, "\t\tName: %s\n", openstack.Name) - fmt.Fprintf(out, "\t\tBucket: %s\n", openstack.BucketName) - fmt.Fprintf(out, "\t\tAccess key: %s\n", openstack.AccessKey) - fmt.Fprintf(out, "\t\tUser: %s\n", openstack.User) - fmt.Fprintf(out, "\t\tURL: %s\n", openstack.URL) - fmt.Fprintf(out, "\t\tPath: %s\n", openstack.Path) - fmt.Fprintf(out, "\t\tPeriod: %d\n", openstack.Period) - fmt.Fprintf(out, "\t\tGZip level: %d\n", openstack.GzipLevel) - fmt.Fprintf(out, "\t\tFormat: %s\n", openstack.Format) - fmt.Fprintf(out, "\t\tFormat version: %d\n", openstack.FormatVersion) - fmt.Fprintf(out, "\t\tResponse condition: %s\n", openstack.ResponseCondition) - fmt.Fprintf(out, "\t\tMessage type: %s\n", openstack.MessageType) - fmt.Fprintf(out, "\t\tTimestamp format: %s\n", openstack.TimestampFormat) - fmt.Fprintf(out, "\t\tPlacement: %s\n", openstack.Placement) - fmt.Fprintf(out, "\t\tPublic key: %s\n", openstack.PublicKey) - fmt.Fprintf(out, "\t\tCompression codec: %s\n", openstack.CompressionCodec) - } - fmt.Fprintln(out) - - return nil -} diff --git a/pkg/logging/openstack/openstack_integration_test.go b/pkg/logging/openstack/openstack_integration_test.go deleted file mode 100644 index 980fc124c..000000000 --- a/pkg/logging/openstack/openstack_integration_test.go +++ /dev/null @@ -1,489 +0,0 @@ -package openstack_test - -import ( - "bytes" - "errors" - "io" - "net/http" - "strings" - "testing" - - "github.com/fastly/cli/pkg/app" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/mock" - "github.com/fastly/cli/pkg/testutil" - "github.com/fastly/cli/pkg/update" - "github.com/fastly/go-fastly/v3/fastly" -) - -func TestOpenstackCreate(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "openstack", "create", "--service-id", "123", "--version", "1", "--name", "log", "--access-key", "foo", "--user", "user", "--url", "https://example.com"}, - wantError: "error parsing arguments: required flag --bucket not provided", - }, - { - args: []string{"logging", "openstack", "create", "--service-id", "123", "--version", "1", "--name", "log", "--bucket", "log", "--user", "user", "--url", "https://example.com"}, - wantError: "error parsing arguments: required flag --access-key not provided", - }, - { - args: []string{"logging", "openstack", "create", "--service-id", "123", "--version", "1", "--name", "log", "--bucket", "log", "--access-key", "foo", "--url", "https://example.com"}, - wantError: "error parsing arguments: required flag --user not provided", - }, - { - args: []string{"logging", "openstack", "create", "--service-id", "123", "--version", "1", "--name", "log", "--bucket", "log", "--access-key", "foo", "--user", "user"}, - wantError: "error parsing arguments: required flag --url not provided", - }, - { - args: []string{"logging", "openstack", "create", "--service-id", "123", "--version", "1", "--name", "log", "--bucket", "log", "--access-key", "foo", "--user", "user", "--url", "https://example.com"}, - api: mock.API{CreateOpenstackFn: createOpenstackOK}, - wantOutput: "Created OpenStack logging endpoint log (service 123 version 1)", - }, - { - args: []string{"logging", "openstack", "create", "--service-id", "123", "--version", "1", "--name", "log", "--bucket", "log", "--access-key", "foo", "--user", "user", "--url", "https://example.com"}, - api: mock.API{CreateOpenstackFn: createOpenstackError}, - wantError: errTest.Error(), - }, - { - args: []string{"logging", "openstack", "create", "--service-id", "123", "--version", "1", "--name", "log", "--bucket", "log", "--access-key", "foo", "--user", "user", "--url", "https://example.com", "--compression-codec", "zstd", "--gzip-level", "9"}, - wantError: "error parsing arguments: the --compression-codec flag is mutually exclusive with the --gzip-level flag", - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertStringContains(t, out.String(), testcase.wantOutput) - }) - } -} - -func TestOpenstackList(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "openstack", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListOpenstacksFn: listOpenstacksOK}, - wantOutput: listOpenstacksShortOutput, - }, - { - args: []string{"logging", "openstack", "list", "--service-id", "123", "--version", "1", "--verbose"}, - api: mock.API{ListOpenstacksFn: listOpenstacksOK}, - wantOutput: listOpenstacksVerboseOutput, - }, - { - args: []string{"logging", "openstack", "list", "--service-id", "123", "--version", "1", "-v"}, - api: mock.API{ListOpenstacksFn: listOpenstacksOK}, - wantOutput: listOpenstacksVerboseOutput, - }, - { - args: []string{"logging", "openstack", "--verbose", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListOpenstacksFn: listOpenstacksOK}, - wantOutput: listOpenstacksVerboseOutput, - }, - { - args: []string{"logging", "-v", "openstack", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListOpenstacksFn: listOpenstacksOK}, - wantOutput: listOpenstacksVerboseOutput, - }, - { - args: []string{"logging", "openstack", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListOpenstacksFn: listOpenstacksError}, - wantError: errTest.Error(), - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertString(t, testcase.wantOutput, out.String()) - }) - } -} - -func TestOpenstackDescribe(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "openstack", "describe", "--service-id", "123", "--version", "1"}, - wantError: "error parsing arguments: required flag --name not provided", - }, - { - args: []string{"logging", "openstack", "describe", "--service-id", "123", "--version", "1", "--name", "logs"}, - api: mock.API{GetOpenstackFn: getOpenstackError}, - wantError: errTest.Error(), - }, - { - args: []string{"logging", "openstack", "describe", "--service-id", "123", "--version", "1", "--name", "logs"}, - api: mock.API{GetOpenstackFn: getOpenstackOK}, - wantOutput: describeOpenstackOutput, - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertString(t, testcase.wantOutput, out.String()) - }) - } -} - -func TestOpenstackUpdate(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "openstack", "update", "--service-id", "123", "--version", "1", "--new-name", "log"}, - wantError: "error parsing arguments: required flag --name not provided", - }, - { - args: []string{"logging", "openstack", "update", "--service-id", "123", "--version", "1", "--name", "logs", "--new-name", "log"}, - api: mock.API{UpdateOpenstackFn: updateOpenstackError}, - wantError: errTest.Error(), - }, - { - args: []string{"logging", "openstack", "update", "--service-id", "123", "--version", "1", "--name", "logs", "--new-name", "log"}, - api: mock.API{UpdateOpenstackFn: updateOpenstackOK}, - wantOutput: "Updated OpenStack logging endpoint log (service 123 version 1)", - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertStringContains(t, out.String(), testcase.wantOutput) - }) - } -} - -func TestOpenstackDelete(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "openstack", "delete", "--service-id", "123", "--version", "1"}, - wantError: "error parsing arguments: required flag --name not provided", - }, - { - args: []string{"logging", "openstack", "delete", "--service-id", "123", "--version", "1", "--name", "logs"}, - api: mock.API{DeleteOpenstackFn: deleteOpenstackError}, - wantError: errTest.Error(), - }, - { - args: []string{"logging", "openstack", "delete", "--service-id", "123", "--version", "1", "--name", "logs"}, - api: mock.API{DeleteOpenstackFn: deleteOpenstackOK}, - wantOutput: "Deleted OpenStack logging endpoint logs (service 123 version 1)", - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertStringContains(t, out.String(), testcase.wantOutput) - }) - } -} - -var errTest = errors.New("fixture error") - -func createOpenstackOK(i *fastly.CreateOpenstackInput) (*fastly.Openstack, error) { - s := fastly.Openstack{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - } - - if i.Name != "" { - s.Name = i.Name - } - - return &s, nil -} - -func createOpenstackError(i *fastly.CreateOpenstackInput) (*fastly.Openstack, error) { - return nil, errTest -} - -func listOpenstacksOK(i *fastly.ListOpenstackInput) ([]*fastly.Openstack, error) { - return []*fastly.Openstack{ - { - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "logs", - BucketName: "my-logs", - AccessKey: "1234", - User: "user", - URL: "https://example.com", - Path: "logs/", - Period: 3600, - GzipLevel: 0, - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - ResponseCondition: "Prevent default logging", - MessageType: "classic", - TimestampFormat: "%Y-%m-%dT%H:%M:%S.000", - Placement: "none", - PublicKey: pgpPublicKey(), - CompressionCodec: "zstd", - }, - { - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "analytics", - BucketName: "analytics", - AccessKey: "1234", - User: "user2", - URL: "https://two.example.com", - Path: "logs/", - Period: 86400, - GzipLevel: 0, - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - MessageType: "classic", - ResponseCondition: "Prevent default logging", - TimestampFormat: "%Y-%m-%dT%H:%M:%S.000", - Placement: "none", - PublicKey: pgpPublicKey(), - CompressionCodec: "zstd", - }, - }, nil -} - -func listOpenstacksError(i *fastly.ListOpenstackInput) ([]*fastly.Openstack, error) { - return nil, errTest -} - -var listOpenstacksShortOutput = strings.TrimSpace(` -SERVICE VERSION NAME -123 1 logs -123 1 analytics -`) + "\n" - -var listOpenstacksVerboseOutput = strings.TrimSpace(` -Fastly API token not provided -Fastly API endpoint: https://api.fastly.com -Service ID: 123 -Version: 1 - Openstack 1/2 - Service ID: 123 - Version: 1 - Name: logs - Bucket: my-logs - Access key: 1234 - User: user - URL: https://example.com - Path: logs/ - Period: 3600 - GZip level: 0 - Format: %h %l %u %t "%r" %>s %b - Format version: 2 - Response condition: Prevent default logging - Message type: classic - Timestamp format: %Y-%m-%dT%H:%M:%S.000 - Placement: none - Public key: `+pgpPublicKey()+` - Compression codec: zstd - Openstack 2/2 - Service ID: 123 - Version: 1 - Name: analytics - Bucket: analytics - Access key: 1234 - User: user2 - URL: https://two.example.com - Path: logs/ - Period: 86400 - GZip level: 0 - Format: %h %l %u %t "%r" %>s %b - Format version: 2 - Response condition: Prevent default logging - Message type: classic - Timestamp format: %Y-%m-%dT%H:%M:%S.000 - Placement: none - Public key: `+pgpPublicKey()+` - Compression codec: zstd -`) + "\n\n" - -func getOpenstackOK(i *fastly.GetOpenstackInput) (*fastly.Openstack, error) { - return &fastly.Openstack{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "logs", - BucketName: "my-logs", - AccessKey: "1234", - User: "user", - URL: "https://example.com", - Path: "logs/", - Period: 3600, - GzipLevel: 0, - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - ResponseCondition: "Prevent default logging", - MessageType: "classic", - TimestampFormat: "%Y-%m-%dT%H:%M:%S.000", - Placement: "none", - PublicKey: pgpPublicKey(), - CompressionCodec: "zstd", - }, nil -} - -func getOpenstackError(i *fastly.GetOpenstackInput) (*fastly.Openstack, error) { - return nil, errTest -} - -var describeOpenstackOutput = strings.TrimSpace(` -Service ID: 123 -Version: 1 -Name: logs -Bucket: my-logs -Access key: 1234 -User: user -URL: https://example.com -Path: logs/ -Period: 3600 -GZip level: 0 -Format: %h %l %u %t "%r" %>s %b -Format version: 2 -Response condition: Prevent default logging -Message type: classic -Timestamp format: %Y-%m-%dT%H:%M:%S.000 -Placement: none -Public key: `+pgpPublicKey()+` -Compression codec: zstd -`) + "\n" - -func updateOpenstackOK(i *fastly.UpdateOpenstackInput) (*fastly.Openstack, error) { - return &fastly.Openstack{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "log", - BucketName: "my-logs", - AccessKey: "1234", - User: "userupdate", - URL: "https://update.example.com", - Path: "logs/", - Period: 3600, - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - ResponseCondition: "Prevent default logging", - MessageType: "classic", - TimestampFormat: "%Y-%m-%dT%H:%M:%S.000", - Placement: "none", - PublicKey: pgpPublicKey(), - CompressionCodec: "zstd", - }, nil -} - -func updateOpenstackError(i *fastly.UpdateOpenstackInput) (*fastly.Openstack, error) { - return nil, errTest -} - -func deleteOpenstackOK(i *fastly.DeleteOpenstackInput) error { - return nil -} - -func deleteOpenstackError(i *fastly.DeleteOpenstackInput) error { - return errTest -} - -// pgpPublicKey returns a PEM encoded PGP public key suitable for testing. -func pgpPublicKey() string { - return strings.TrimSpace(`-----BEGIN PGP PUBLIC KEY BLOCK----- -mQENBFyUD8sBCACyFnB39AuuTygseek+eA4fo0cgwva6/FSjnWq7riouQee8GgQ/ -ibXTRyv4iVlwI12GswvMTIy7zNvs1R54i0qvsLr+IZ4GVGJqs6ZJnvQcqe3xPoR4 -8AnBfw90o32r/LuHf6QCJXi+AEu35koNlNAvLJ2B+KACaNB7N0EeWmqpV/1V2k9p -lDYk+th7LcCuaFNGqKS/PrMnnMqR6VDLCjHhNx4KR79b0Twm/2qp6an3hyNRu8Gn -dwxpf1/BUu3JWf+LqkN4Y3mbOmSUL3MaJNvyQguUzTfS0P0uGuBDHrJCVkMZCzDB -89ag55jCPHyGeHBTd02gHMWzsg3WMBWvCsrzABEBAAG0JXRlcnJhZm9ybSAodGVz -dCkgPHRlc3RAdGVycmFmb3JtLmNvbT6JAU4EEwEIADgWIQSHYyc6Kj9l6HzQsau6 -vFFc9jxV/wUCXJQPywIbAwULCQgHAgYVCgkICwIEFgIDAQIeAQIXgAAKCRC6vFFc -9jxV/815CAClb32OxV7wG01yF97TzlyTl8TnvjMtoG29Mw4nSyg+mjM3b8N7iXm9 -OLX59fbDAWtBSldSZE22RXd3CvlFOG/EnKBXSjBtEqfyxYSnyOPkMPBYWGL/ApkX -SvPYJ4LKdvipYToKFh3y9kk2gk1DcDBDyaaHvR+3rv1u3aoy7/s2EltAfDS3ZQIq -7/cWTLJml/lleeB/Y6rPj8xqeCYhE5ahw9gsV/Mdqatl24V9Tks30iijx0Hhw+Gx -kATUikMGr2GDVqoIRga5kXI7CzYff4rkc0Twn47fMHHHe/KY9M2yVnMHUXmAZwbG -M1cMI/NH1DjevCKdGBLcRJlhuLPKF/anuQENBFyUD8sBCADIpd7r7GuPd6n/Ikxe -u6h7umV6IIPoAm88xCYpTbSZiaK30Svh6Ywra9jfE2KlU9o6Y/art8ip0VJ3m07L -4RSfSpnzqgSwdjSq5hNour2Fo/BzYhK7yaz2AzVSbe33R0+RYhb4b/6N+bKbjwGF -ftCsqVFMH+PyvYkLbvxyQrHlA9woAZaNThI1ztO5rGSnGUR8xt84eup28WIFKg0K -UEGUcTzz+8QGAwAra+0ewPXo/AkO+8BvZjDidP417u6gpBHOJ9qYIcO9FxHeqFyu -YrjlrxowEgXn5wO8xuNz6Vu1vhHGDHGDsRbZF8pv1d5O+0F1G7ttZ2GRRgVBZPwi -kiyRABEBAAGJATYEGAEIACAWIQSHYyc6Kj9l6HzQsau6vFFc9jxV/wUCXJQPywIb -DAAKCRC6vFFc9jxV/9YOCACe8qmOSnKQpQfW+PqYOqo3dt7JyweTs3FkD6NT8Zml -dYy/vkstbTjPpX6aTvUZjkb46BVi7AOneVHpD5GBqvRsZ9iVgDYHaehmLCdKiG5L -3Tp90NN+QY5WDbsGmsyk6+6ZMYejb4qYfweQeduOj27aavCJdLkCYMoRKfcFYI8c -FaNmEfKKy/r1PO20NXEG6t9t05K/frHy6ZG8bCNYdpagfFVot47r9JaQqWlTNtIR -5+zkkSq/eG9BEtRij3a6cTdQbktdBzx2KBeI0PYc1vlZR0LpuFKZqY9vlE6vTGLR -wMfrTEOvx0NxUM3rpaCgEmuWbB1G1Hu371oyr4srrr+N -=28dr ------END PGP PUBLIC KEY BLOCK----- -`) -} diff --git a/pkg/logging/openstack/openstack_test.go b/pkg/logging/openstack/openstack_test.go deleted file mode 100644 index 4ab767c28..000000000 --- a/pkg/logging/openstack/openstack_test.go +++ /dev/null @@ -1,269 +0,0 @@ -package openstack - -import ( - "strings" - "testing" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/mock" - "github.com/fastly/cli/pkg/testutil" - "github.com/fastly/go-fastly/v3/fastly" -) - -func TestCreateOpenstackInput(t *testing.T) { - for _, testcase := range []struct { - name string - cmd *CreateCommand - want *fastly.CreateOpenstackInput - wantError string - }{ - { - name: "required values set flag serviceID", - cmd: createCommandRequired(), - want: &fastly.CreateOpenstackInput{ - ServiceID: "123", - ServiceVersion: 2, - Name: "log", - BucketName: "bucket", - AccessKey: "access", - User: "user", - URL: "https://example.com", - }, - }, - { - name: "all values set flag serviceID", - cmd: createCommandAll(), - want: &fastly.CreateOpenstackInput{ - ServiceID: "123", - ServiceVersion: 2, - Name: "log", - BucketName: "bucket", - AccessKey: "access", - User: "user", - URL: "https://example.com", - Path: "/log", - Period: 3600, - Format: `%h %l %u %t "%r" %>s %b`, - MessageType: "classic", - FormatVersion: 2, - ResponseCondition: "Prevent default logging", - TimestampFormat: "%Y-%m-%dT%H:%M:%S.000", - Placement: "none", - PublicKey: pgpPublicKey(), - CompressionCodec: "zstd", - }, - }, - { - name: "error missing serviceID", - cmd: createCommandMissingServiceID(), - want: nil, - wantError: errors.ErrNoServiceID.Error(), - }, - } { - t.Run(testcase.name, func(t *testing.T) { - have, err := testcase.cmd.createInput() - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertEqual(t, testcase.want, have) - }) - } -} - -func TestUpdateOpenstackInput(t *testing.T) { - for _, testcase := range []struct { - name string - cmd *UpdateCommand - api mock.API - want *fastly.UpdateOpenstackInput - wantError string - }{ - { - name: "all values set flag serviceID", - cmd: updateCommandAll(), - api: mock.API{GetOpenstackFn: getOpenstackOK}, - want: &fastly.UpdateOpenstackInput{ - ServiceID: "123", - ServiceVersion: 2, - Name: "log", - NewName: fastly.String("new1"), - BucketName: fastly.String("new2"), - User: fastly.String("new3"), - AccessKey: fastly.String("new4"), - URL: fastly.String("new5"), - Path: fastly.String("new6"), - Period: fastly.Uint(3601), - GzipLevel: fastly.Uint(0), - Format: fastly.String("new7"), - FormatVersion: fastly.Uint(3), - ResponseCondition: fastly.String("new8"), - MessageType: fastly.String("new9"), - TimestampFormat: fastly.String("new10"), - Placement: fastly.String("new11"), - PublicKey: fastly.String("new12"), - CompressionCodec: fastly.String("new13"), - }, - }, - { - name: "no updates", - cmd: updateCommandNoUpdates(), - api: mock.API{GetOpenstackFn: getOpenstackOK}, - want: &fastly.UpdateOpenstackInput{ - ServiceID: "123", - ServiceVersion: 2, - Name: "log", - }, - }, - { - name: "error missing serviceID", - cmd: updateCommandMissingServiceID(), - want: nil, - wantError: errors.ErrNoServiceID.Error(), - }, - } { - t.Run(testcase.name, func(t *testing.T) { - testcase.cmd.Base.Globals.Client = testcase.api - - have, err := testcase.cmd.createInput() - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertEqual(t, testcase.want, have) - }) - } -} - -func createCommandRequired() *CreateCommand { - return &CreateCommand{ - manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, - EndpointName: "log", - Version: 2, - BucketName: "bucket", - AccessKey: "access", - User: "user", - URL: "https://example.com", - } -} - -func createCommandAll() *CreateCommand { - return &CreateCommand{ - manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, - EndpointName: "log", - Version: 2, - BucketName: "bucket", - AccessKey: "access", - User: "user", - URL: "https://example.com", - Path: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "/log"}, - Period: common.OptionalUint{Optional: common.Optional{WasSet: true}, Value: 3600}, - Format: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: `%h %l %u %t "%r" %>s %b`}, - FormatVersion: common.OptionalUint{Optional: common.Optional{WasSet: true}, Value: 2}, - ResponseCondition: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "Prevent default logging"}, - TimestampFormat: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "%Y-%m-%dT%H:%M:%S.000"}, - Placement: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "none"}, - MessageType: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "classic"}, - PublicKey: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: pgpPublicKey()}, - CompressionCodec: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "zstd"}, - } -} - -func createCommandMissingServiceID() *CreateCommand { - res := createCommandAll() - res.manifest = manifest.Data{} - return res -} - -func updateCommandNoUpdates() *UpdateCommand { - return &UpdateCommand{ - Base: common.Base{Globals: &config.Data{Client: nil}}, - manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, - EndpointName: "log", - Version: 2, - } -} - -func updateCommandAll() *UpdateCommand { - return &UpdateCommand{ - Base: common.Base{Globals: &config.Data{Client: nil}}, - manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, - EndpointName: "log", - Version: 2, - NewName: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new1"}, - BucketName: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new2"}, - User: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new3"}, - AccessKey: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new4"}, - URL: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new5"}, - Path: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new6"}, - Period: common.OptionalUint{Optional: common.Optional{WasSet: true}, Value: 3601}, - GzipLevel: common.OptionalUint{Optional: common.Optional{WasSet: true}, Value: 0}, - Format: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new7"}, - FormatVersion: common.OptionalUint{Optional: common.Optional{WasSet: true}, Value: 3}, - ResponseCondition: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new8"}, - MessageType: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new9"}, - TimestampFormat: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new10"}, - Placement: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new11"}, - PublicKey: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new12"}, - CompressionCodec: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new13"}, - } -} - -func updateCommandMissingServiceID() *UpdateCommand { - res := updateCommandAll() - res.manifest = manifest.Data{} - return res -} - -func getOpenstackOK(i *fastly.GetOpenstackInput) (*fastly.Openstack, error) { - return &fastly.Openstack{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "logs", - BucketName: "bucket", - AccessKey: "access", - User: "user", - URL: "https://example.com", - Path: "/log", - Period: 3600, - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - ResponseCondition: "Prevent default logging", - MessageType: "classic", - TimestampFormat: "%Y-%m-%dT%H:%M:%S.000", - Placement: "none", - PublicKey: pgpPublicKey(), - CompressionCodec: "zstd", - }, nil -} - -// pgpPublicKey returns a PEM encoded PGP public key suitable for testing. -func pgpPublicKey() string { - return strings.TrimSpace(`-----BEGIN PGP PUBLIC KEY BLOCK----- -mQENBFyUD8sBCACyFnB39AuuTygseek+eA4fo0cgwva6/FSjnWq7riouQee8GgQ/ -ibXTRyv4iVlwI12GswvMTIy7zNvs1R54i0qvsLr+IZ4GVGJqs6ZJnvQcqe3xPoR4 -8AnBfw90o32r/LuHf6QCJXi+AEu35koNlNAvLJ2B+KACaNB7N0EeWmqpV/1V2k9p -lDYk+th7LcCuaFNGqKS/PrMnnMqR6VDLCjHhNx4KR79b0Twm/2qp6an3hyNRu8Gn -dwxpf1/BUu3JWf+LqkN4Y3mbOmSUL3MaJNvyQguUzTfS0P0uGuBDHrJCVkMZCzDB -89ag55jCPHyGeHBTd02gHMWzsg3WMBWvCsrzABEBAAG0JXRlcnJhZm9ybSAodGVz -dCkgPHRlc3RAdGVycmFmb3JtLmNvbT6JAU4EEwEIADgWIQSHYyc6Kj9l6HzQsau6 -vFFc9jxV/wUCXJQPywIbAwULCQgHAgYVCgkICwIEFgIDAQIeAQIXgAAKCRC6vFFc -9jxV/815CAClb32OxV7wG01yF97TzlyTl8TnvjMtoG29Mw4nSyg+mjM3b8N7iXm9 -OLX59fbDAWtBSldSZE22RXd3CvlFOG/EnKBXSjBtEqfyxYSnyOPkMPBYWGL/ApkX -SvPYJ4LKdvipYToKFh3y9kk2gk1DcDBDyaaHvR+3rv1u3aoy7/s2EltAfDS3ZQIq -7/cWTLJml/lleeB/Y6rPj8xqeCYhE5ahw9gsV/Mdqatl24V9Tks30iijx0Hhw+Gx -kATUikMGr2GDVqoIRga5kXI7CzYff4rkc0Twn47fMHHHe/KY9M2yVnMHUXmAZwbG -M1cMI/NH1DjevCKdGBLcRJlhuLPKF/anuQENBFyUD8sBCADIpd7r7GuPd6n/Ikxe -u6h7umV6IIPoAm88xCYpTbSZiaK30Svh6Ywra9jfE2KlU9o6Y/art8ip0VJ3m07L -4RSfSpnzqgSwdjSq5hNour2Fo/BzYhK7yaz2AzVSbe33R0+RYhb4b/6N+bKbjwGF -ftCsqVFMH+PyvYkLbvxyQrHlA9woAZaNThI1ztO5rGSnGUR8xt84eup28WIFKg0K -UEGUcTzz+8QGAwAra+0ewPXo/AkO+8BvZjDidP417u6gpBHOJ9qYIcO9FxHeqFyu -YrjlrxowEgXn5wO8xuNz6Vu1vhHGDHGDsRbZF8pv1d5O+0F1G7ttZ2GRRgVBZPwi -kiyRABEBAAGJATYEGAEIACAWIQSHYyc6Kj9l6HzQsau6vFFc9jxV/wUCXJQPywIb -DAAKCRC6vFFc9jxV/9YOCACe8qmOSnKQpQfW+PqYOqo3dt7JyweTs3FkD6NT8Zml -dYy/vkstbTjPpX6aTvUZjkb46BVi7AOneVHpD5GBqvRsZ9iVgDYHaehmLCdKiG5L -3Tp90NN+QY5WDbsGmsyk6+6ZMYejb4qYfweQeduOj27aavCJdLkCYMoRKfcFYI8c -FaNmEfKKy/r1PO20NXEG6t9t05K/frHy6ZG8bCNYdpagfFVot47r9JaQqWlTNtIR -5+zkkSq/eG9BEtRij3a6cTdQbktdBzx2KBeI0PYc1vlZR0LpuFKZqY9vlE6vTGLR -wMfrTEOvx0NxUM3rpaCgEmuWbB1G1Hu371oyr4srrr+N -=28dr ------END PGP PUBLIC KEY BLOCK----- -`) -} diff --git a/pkg/logging/openstack/root.go b/pkg/logging/openstack/root.go deleted file mode 100644 index 48dcadf5f..000000000 --- a/pkg/logging/openstack/root.go +++ /dev/null @@ -1,28 +0,0 @@ -package openstack - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/config" -) - -// RootCommand is the parent command for all subcommands in this package. -// It should be installed under the primary root command. -type RootCommand struct { - common.Base - // no flags -} - -// NewRootCommand returns a new command registered in the parent. -func NewRootCommand(parent common.Registerer, globals *config.Data) *RootCommand { - var c RootCommand - c.Globals = globals - c.CmdClause = parent.Command("openstack", "Manipulate Fastly service version OpenStack logging endpoints") - return &c -} - -// Exec implements the command interface. -func (c *RootCommand) Exec(in io.Reader, out io.Writer) error { - panic("unreachable") -} diff --git a/pkg/logging/openstack/update.go b/pkg/logging/openstack/update.go deleted file mode 100644 index 9d048dd2a..000000000 --- a/pkg/logging/openstack/update.go +++ /dev/null @@ -1,170 +0,0 @@ -package openstack - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// UpdateCommand calls the Fastly API to update an OpenStack logging endpoint. -type UpdateCommand struct { - common.Base - manifest manifest.Data - - //required - EndpointName string - Version int - - // optional - NewName common.OptionalString - BucketName common.OptionalString - AccessKey common.OptionalString - User common.OptionalString - URL common.OptionalString - Path common.OptionalString - Period common.OptionalUint - GzipLevel common.OptionalUint - Format common.OptionalString - FormatVersion common.OptionalUint - ResponseCondition common.OptionalString - MessageType common.OptionalString - TimestampFormat common.OptionalString - Placement common.OptionalString - PublicKey common.OptionalString - CompressionCodec common.OptionalString -} - -// NewUpdateCommand returns a usable command registered under the parent. -func NewUpdateCommand(parent common.Registerer, globals *config.Data) *UpdateCommand { - var c UpdateCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - - c.CmdClause = parent.Command("update", "Update an OpenStack logging endpoint on a Fastly service version") - - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Version) - c.CmdClause.Flag("name", "The name of the OpenStack logging object").Short('n').Required().StringVar(&c.EndpointName) - - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("new-name", "New name of the OpenStack logging object").Action(c.NewName.Set).StringVar(&c.NewName.Value) - c.CmdClause.Flag("bucket", "The name of the Openstack Space").Action(c.BucketName.Set).StringVar(&c.BucketName.Value) - c.CmdClause.Flag("access-key", "Your OpenStack account access key").Action(c.AccessKey.Set).StringVar(&c.AccessKey.Value) - c.CmdClause.Flag("user", "The username for your OpenStack account.").Action(c.User.Set).StringVar(&c.User.Value) - c.CmdClause.Flag("url", "Your OpenStack auth url.").Action(c.URL.Set).StringVar(&c.URL.Value) - c.CmdClause.Flag("path", "The path to upload logs to").Action(c.Path.Set).StringVar(&c.Path.Value) - c.CmdClause.Flag("period", "How frequently log files are finalized so they can be available for reading (in seconds, default 3600)").Action(c.Period.Set).UintVar(&c.Period.Value) - c.CmdClause.Flag("gzip-level", "What level of GZIP encoding to have when dumping logs (default 0, no compression)").Action(c.GzipLevel.Set).UintVar(&c.GzipLevel.Value) - c.CmdClause.Flag("format", "Apache style log formatting").Action(c.Format.Set).StringVar(&c.Format.Value) - c.CmdClause.Flag("format-version", "The version of the custom logging format used for the configured endpoint. Can be either 2 (default) or 1").Action(c.FormatVersion.Set).UintVar(&c.FormatVersion.Value) - c.CmdClause.Flag("response-condition", "The name of an existing condition in the configured endpoint, or leave blank to always execute").Action(c.ResponseCondition.Set).StringVar(&c.ResponseCondition.Value) - c.CmdClause.Flag("message-type", "How the message should be formatted. One of: classic (default), loggly, logplex or blank").Action(c.MessageType.Set).StringVar(&c.MessageType.Value) - c.CmdClause.Flag("timestamp-format", `strftime specified timestamp formatting (default "%Y-%m-%dT%H:%M:%S.000")`).Action(c.TimestampFormat.Set).StringVar(&c.TimestampFormat.Value) - c.CmdClause.Flag("placement", "Where in the generated VCL the logging call should be placed, overriding any format_version default. Can be none or waf_debug").Action(c.Placement.Set).StringVar(&c.Placement.Value) - c.CmdClause.Flag("public-key", "A PGP public key that Fastly will use to encrypt your log files before writing them to disk").Action(c.PublicKey.Set).StringVar(&c.PublicKey.Value) - c.CmdClause.Flag("compression-codec", `The codec used for compression of your logs. Valid values are zstd, snappy, and gzip. If the specified codec is "gzip", gzip_level will default to 3. To specify a different level, leave compression_codec blank and explicitly set the level using gzip_level. Specifying both compression_codec and gzip_level in the same API request will result in an error.`).Action(c.CompressionCodec.Set).StringVar(&c.CompressionCodec.Value) - - return &c -} - -// createInput transforms values parsed from CLI flags into an object to be used by the API client library. -func (c *UpdateCommand) createInput() (*fastly.UpdateOpenstackInput, error) { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return nil, errors.ErrNoServiceID - } - - input := fastly.UpdateOpenstackInput{ - ServiceID: serviceID, - ServiceVersion: c.Version, - Name: c.EndpointName, - } - - // Set new values if set by user. - if c.NewName.WasSet { - input.NewName = fastly.String(c.NewName.Value) - } - - if c.BucketName.WasSet { - input.BucketName = fastly.String(c.BucketName.Value) - } - - if c.AccessKey.WasSet { - input.AccessKey = fastly.String(c.AccessKey.Value) - } - - if c.User.WasSet { - input.User = fastly.String(c.User.Value) - } - - if c.URL.WasSet { - input.URL = fastly.String(c.URL.Value) - } - - if c.Path.WasSet { - input.Path = fastly.String(c.Path.Value) - } - - if c.Period.WasSet { - input.Period = fastly.Uint(c.Period.Value) - } - - if c.GzipLevel.WasSet { - input.GzipLevel = fastly.Uint(c.GzipLevel.Value) - } - - if c.Format.WasSet { - input.Format = fastly.String(c.Format.Value) - } - - if c.FormatVersion.WasSet { - input.FormatVersion = fastly.Uint(c.FormatVersion.Value) - } - - if c.ResponseCondition.WasSet { - input.ResponseCondition = fastly.String(c.ResponseCondition.Value) - } - - if c.MessageType.WasSet { - input.MessageType = fastly.String(c.MessageType.Value) - } - - if c.TimestampFormat.WasSet { - input.TimestampFormat = fastly.String(c.TimestampFormat.Value) - } - - if c.Placement.WasSet { - input.Placement = fastly.String(c.Placement.Value) - } - - if c.PublicKey.WasSet { - input.PublicKey = fastly.String(c.PublicKey.Value) - } - - if c.CompressionCodec.WasSet { - input.CompressionCodec = fastly.String(c.CompressionCodec.Value) - } - - return &input, nil -} - -// Exec invokes the application logic for the command. -func (c *UpdateCommand) Exec(in io.Reader, out io.Writer) error { - input, err := c.createInput() - if err != nil { - return err - } - - openstack, err := c.Globals.Client.UpdateOpenstack(input) - if err != nil { - return err - } - - text.Success(out, "Updated OpenStack logging endpoint %s (service %s version %d)", openstack.Name, openstack.ServiceID, openstack.ServiceVersion) - return nil -} diff --git a/pkg/logging/papertrail/create.go b/pkg/logging/papertrail/create.go deleted file mode 100644 index 9b14c9a35..000000000 --- a/pkg/logging/papertrail/create.go +++ /dev/null @@ -1,105 +0,0 @@ -package papertrail - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// CreateCommand calls the Fastly API to create a Papertrail logging endpoint. -type CreateCommand struct { - common.Base - manifest manifest.Data - - // required - EndpointName string // Can't shadow common.Base method Name(). - Version int - Address string - - // optional - Port common.OptionalUint - Format common.OptionalString - FormatVersion common.OptionalUint - Placement common.OptionalString - ResponseCondition common.OptionalString -} - -// NewCreateCommand returns a usable command registered under the parent. -func NewCreateCommand(parent common.Registerer, globals *config.Data) *CreateCommand { - var c CreateCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("create", "Create a Papertrail logging endpoint on a Fastly service version").Alias("add") - - c.CmdClause.Flag("name", "The name of the Papertrail logging object. Used as a primary key for API access").Short('n').Required().StringVar(&c.EndpointName) - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Version) - c.CmdClause.Flag("address", "A hostname or IPv4 address").Required().StringVar(&c.Address) - - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("port", "The port number").Action(c.Port.Set).UintVar(&c.Port.Value) - c.CmdClause.Flag("format-version", "The version of the custom logging format used for the configured endpoint. Can be either 2 (the default, version 2 log format) or 1 (the version 1 log format). The logging call gets placed by default in vcl_log if format_version is set to 2 and in vcl_deliver if format_version is set to 1").Action(c.FormatVersion.Set).UintVar(&c.FormatVersion.Value) - c.CmdClause.Flag("format", "Apache style log formatting").Action(c.Format.Set).StringVar(&c.Format.Value) - c.CmdClause.Flag("response-condition", "The name of an existing condition in the configured endpoint, or leave blank to always execute").Action(c.ResponseCondition.Set).StringVar(&c.ResponseCondition.Value) - c.CmdClause.Flag("placement", "Where in the generated VCL the logging call should be placed, overriding any format_version default. Can be none or waf_debug. This field is not required and has no default value").Action(c.Placement.Set).StringVar(&c.Placement.Value) - - return &c -} - -// createInput transforms values parsed from CLI flags into an object to be used by the API client library. -func (c *CreateCommand) createInput() (*fastly.CreatePapertrailInput, error) { - var input fastly.CreatePapertrailInput - - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return nil, errors.ErrNoServiceID - } - - input.ServiceID = serviceID - input.Name = c.EndpointName - input.ServiceVersion = c.Version - input.Address = c.Address - - if c.Port.WasSet { - input.Port = c.Port.Value - } - - if c.Format.WasSet { - input.Format = c.Format.Value - } - - if c.FormatVersion.WasSet { - input.FormatVersion = c.FormatVersion.Value - } - - if c.ResponseCondition.WasSet { - input.ResponseCondition = c.ResponseCondition.Value - } - - if c.Placement.WasSet { - input.Placement = c.Placement.Value - } - - return &input, nil -} - -// Exec invokes the application logic for the command. -func (c *CreateCommand) Exec(in io.Reader, out io.Writer) error { - input, err := c.createInput() - if err != nil { - return err - } - - d, err := c.Globals.Client.CreatePapertrail(input) - if err != nil { - return err - } - - text.Success(out, "Created Papertrail logging endpoint %s (service %s version %d)", d.Name, d.ServiceID, d.ServiceVersion) - return nil -} diff --git a/pkg/logging/papertrail/delete.go b/pkg/logging/papertrail/delete.go deleted file mode 100644 index 8c69cea52..000000000 --- a/pkg/logging/papertrail/delete.go +++ /dev/null @@ -1,51 +0,0 @@ -package papertrail - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// DeleteCommand calls the Fastly API to delete a Papertrail logging endpoint. -type DeleteCommand struct { - common.Base - manifest manifest.Data - Input fastly.DeletePapertrailInput -} - -// NewDeleteCommand returns a usable command registered under the parent. -func NewDeleteCommand(parent common.Registerer, globals *config.Data) *DeleteCommand { - var c DeleteCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("delete", "Delete a Papertrail logging endpoint on a Fastly service version").Alias("remove") - - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Input.ServiceVersion) - c.CmdClause.Flag("name", "The name of the Papertrail logging object").Short('n').Required().StringVar(&c.Input.Name) - - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - - return &c -} - -// Exec invokes the application logic for the command. -func (c *DeleteCommand) Exec(in io.Reader, out io.Writer) error { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - c.Input.ServiceID = serviceID - - if err := c.Globals.Client.DeletePapertrail(&c.Input); err != nil { - return err - } - - text.Success(out, "Deleted Papertrail logging endpoint %s (service %s version %d)", c.Input.Name, c.Input.ServiceID, c.Input.ServiceVersion) - return nil -} diff --git a/pkg/logging/papertrail/describe.go b/pkg/logging/papertrail/describe.go deleted file mode 100644 index 701fbec2c..000000000 --- a/pkg/logging/papertrail/describe.go +++ /dev/null @@ -1,58 +0,0 @@ -package papertrail - -import ( - "fmt" - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/go-fastly/v3/fastly" -) - -// DescribeCommand calls the Fastly API to describe a Papertrail logging endpoint. -type DescribeCommand struct { - common.Base - manifest manifest.Data - Input fastly.GetPapertrailInput -} - -// NewDescribeCommand returns a usable command registered under the parent. -func NewDescribeCommand(parent common.Registerer, globals *config.Data) *DescribeCommand { - var c DescribeCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("describe", "Show detailed information about a Papertrail logging endpoint on a Fastly service version").Alias("get") - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Input.ServiceVersion) - c.CmdClause.Flag("name", "The name of the Papertrail logging object").Short('n').Required().StringVar(&c.Input.Name) - return &c -} - -// Exec invokes the application logic for the command. -func (c *DescribeCommand) Exec(in io.Reader, out io.Writer) error { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - c.Input.ServiceID = serviceID - - papertrail, err := c.Globals.Client.GetPapertrail(&c.Input) - if err != nil { - return err - } - - fmt.Fprintf(out, "Service ID: %s\n", papertrail.ServiceID) - fmt.Fprintf(out, "Version: %d\n", papertrail.ServiceVersion) - fmt.Fprintf(out, "Name: %s\n", papertrail.Name) - fmt.Fprintf(out, "Address: %s\n", papertrail.Address) - fmt.Fprintf(out, "Port: %d\n", papertrail.Port) - fmt.Fprintf(out, "Format: %s\n", papertrail.Format) - fmt.Fprintf(out, "Format version: %d\n", papertrail.FormatVersion) - fmt.Fprintf(out, "Response condition: %s\n", papertrail.ResponseCondition) - fmt.Fprintf(out, "Placement: %s\n", papertrail.Placement) - - return nil -} diff --git a/pkg/logging/papertrail/list.go b/pkg/logging/papertrail/list.go deleted file mode 100644 index 76c845987..000000000 --- a/pkg/logging/papertrail/list.go +++ /dev/null @@ -1,74 +0,0 @@ -package papertrail - -import ( - "fmt" - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// ListCommand calls the Fastly API to list Papertrail logging endpoints. -type ListCommand struct { - common.Base - manifest manifest.Data - Input fastly.ListPapertrailsInput -} - -// NewListCommand returns a usable command registered under the parent. -func NewListCommand(parent common.Registerer, globals *config.Data) *ListCommand { - var c ListCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("list", "List Papertrail endpoints on a Fastly service version") - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Input.ServiceVersion) - return &c -} - -// Exec invokes the application logic for the command. -func (c *ListCommand) Exec(in io.Reader, out io.Writer) error { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - c.Input.ServiceID = serviceID - - papertrails, err := c.Globals.Client.ListPapertrails(&c.Input) - if err != nil { - return err - } - - if !c.Globals.Verbose() { - tw := text.NewTable(out) - tw.AddHeader("SERVICE", "VERSION", "NAME") - for _, papertrail := range papertrails { - tw.AddLine(papertrail.ServiceID, papertrail.ServiceVersion, papertrail.Name) - } - tw.Print() - return nil - } - - fmt.Fprintf(out, "Service ID: %s\n", c.Input.ServiceID) - fmt.Fprintf(out, "Version: %d\n", c.Input.ServiceVersion) - for i, papertrail := range papertrails { - fmt.Fprintf(out, "\tPapertrail %d/%d\n", i+1, len(papertrails)) - fmt.Fprintf(out, "\t\tService ID: %s\n", papertrail.ServiceID) - fmt.Fprintf(out, "\t\tVersion: %d\n", papertrail.ServiceVersion) - fmt.Fprintf(out, "\t\tName: %s\n", papertrail.Name) - fmt.Fprintf(out, "\t\tAddress: %s\n", papertrail.Address) - fmt.Fprintf(out, "\t\tPort: %d\n", papertrail.Port) - fmt.Fprintf(out, "\t\tFormat: %s\n", papertrail.Format) - fmt.Fprintf(out, "\t\tFormat version: %d\n", papertrail.FormatVersion) - fmt.Fprintf(out, "\t\tResponse condition: %s\n", papertrail.ResponseCondition) - fmt.Fprintf(out, "\t\tPlacement: %s\n", papertrail.Placement) - } - fmt.Fprintln(out) - - return nil -} diff --git a/pkg/logging/papertrail/papertrail_integration_test.go b/pkg/logging/papertrail/papertrail_integration_test.go deleted file mode 100644 index 07ac40ec6..000000000 --- a/pkg/logging/papertrail/papertrail_integration_test.go +++ /dev/null @@ -1,372 +0,0 @@ -package papertrail_test - -import ( - "bytes" - "errors" - "io" - "net/http" - "strings" - "testing" - - "github.com/fastly/cli/pkg/app" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/mock" - "github.com/fastly/cli/pkg/testutil" - "github.com/fastly/cli/pkg/update" - "github.com/fastly/go-fastly/v3/fastly" -) - -func TestPapertrailCreate(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "papertrail", "create", "--service-id", "123", "--version", "1", "--name", "log"}, - wantError: "error parsing arguments: required flag --address not provided", - }, - { - args: []string{"logging", "papertrail", "create", "--service-id", "123", "--version", "1", "--name", "log", "--address", "example.com:123"}, - api: mock.API{CreatePapertrailFn: createPapertrailOK}, - wantOutput: "Created Papertrail logging endpoint log (service 123 version 1)", - }, - { - args: []string{"logging", "papertrail", "create", "--service-id", "123", "--version", "1", "--name", "log", "--address", "example.com:123"}, - api: mock.API{CreatePapertrailFn: createPapertrailError}, - wantError: errTest.Error(), - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertStringContains(t, out.String(), testcase.wantOutput) - }) - } -} - -func TestPapertrailList(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "papertrail", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListPapertrailsFn: listPapertrailsOK}, - wantOutput: listPapertrailsShortOutput, - }, - { - args: []string{"logging", "papertrail", "list", "--service-id", "123", "--version", "1", "--verbose"}, - api: mock.API{ListPapertrailsFn: listPapertrailsOK}, - wantOutput: listPapertrailsVerboseOutput, - }, - { - args: []string{"logging", "papertrail", "list", "--service-id", "123", "--version", "1", "-v"}, - api: mock.API{ListPapertrailsFn: listPapertrailsOK}, - wantOutput: listPapertrailsVerboseOutput, - }, - { - args: []string{"logging", "papertrail", "--verbose", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListPapertrailsFn: listPapertrailsOK}, - wantOutput: listPapertrailsVerboseOutput, - }, - { - args: []string{"logging", "-v", "papertrail", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListPapertrailsFn: listPapertrailsOK}, - wantOutput: listPapertrailsVerboseOutput, - }, - { - args: []string{"logging", "papertrail", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListPapertrailsFn: listPapertrailsError}, - wantError: errTest.Error(), - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertString(t, testcase.wantOutput, out.String()) - }) - } -} - -func TestPapertrailDescribe(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "papertrail", "describe", "--service-id", "123", "--version", "1"}, - wantError: "error parsing arguments: required flag --name not provided", - }, - { - args: []string{"logging", "papertrail", "describe", "--service-id", "123", "--version", "1", "--name", "logs"}, - api: mock.API{GetPapertrailFn: getPapertrailError}, - wantError: errTest.Error(), - }, - { - args: []string{"logging", "papertrail", "describe", "--service-id", "123", "--version", "1", "--name", "logs"}, - api: mock.API{GetPapertrailFn: getPapertrailOK}, - wantOutput: describePapertrailOutput, - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertString(t, testcase.wantOutput, out.String()) - }) - } -} - -func TestPapertrailUpdate(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "papertrail", "update", "--service-id", "123", "--version", "1", "--new-name", "log"}, - wantError: "error parsing arguments: required flag --name not provided", - }, - { - args: []string{"logging", "papertrail", "update", "--service-id", "123", "--version", "1", "--name", "logs", "--new-name", "log"}, - api: mock.API{UpdatePapertrailFn: updatePapertrailError}, - wantError: errTest.Error(), - }, - { - args: []string{"logging", "papertrail", "update", "--service-id", "123", "--version", "1", "--name", "logs", "--new-name", "log"}, - api: mock.API{UpdatePapertrailFn: updatePapertrailOK}, - wantOutput: "Updated Papertrail logging endpoint log (service 123 version 1)", - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertStringContains(t, out.String(), testcase.wantOutput) - }) - } -} - -func TestPapertrailDelete(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "papertrail", "delete", "--service-id", "123", "--version", "1"}, - wantError: "error parsing arguments: required flag --name not provided", - }, - { - args: []string{"logging", "papertrail", "delete", "--service-id", "123", "--version", "1", "--name", "logs"}, - api: mock.API{DeletePapertrailFn: deletePapertrailError}, - wantError: errTest.Error(), - }, - { - args: []string{"logging", "papertrail", "delete", "--service-id", "123", "--version", "1", "--name", "logs"}, - api: mock.API{DeletePapertrailFn: deletePapertrailOK}, - wantOutput: "Deleted Papertrail logging endpoint logs (service 123 version 1)", - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertStringContains(t, out.String(), testcase.wantOutput) - }) - } -} - -var errTest = errors.New("fixture error") - -func createPapertrailOK(i *fastly.CreatePapertrailInput) (*fastly.Papertrail, error) { - return &fastly.Papertrail{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: i.Name, - }, nil -} - -func createPapertrailError(i *fastly.CreatePapertrailInput) (*fastly.Papertrail, error) { - return nil, errTest -} - -func listPapertrailsOK(i *fastly.ListPapertrailsInput) ([]*fastly.Papertrail, error) { - return []*fastly.Papertrail{ - { - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "logs", - Address: "example.com:123", - Port: 123, - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - ResponseCondition: "Prevent default logging", - Placement: "none", - }, - { - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "analytics", - Address: "127.0.0.1:456", - Port: 456, - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - ResponseCondition: "Prevent default logging", - Placement: "none", - }, - }, nil -} - -func listPapertrailsError(i *fastly.ListPapertrailsInput) ([]*fastly.Papertrail, error) { - return nil, errTest -} - -var listPapertrailsShortOutput = strings.TrimSpace(` -SERVICE VERSION NAME -123 1 logs -123 1 analytics -`) + "\n" - -var listPapertrailsVerboseOutput = strings.TrimSpace(` -Fastly API token not provided -Fastly API endpoint: https://api.fastly.com -Service ID: 123 -Version: 1 - Papertrail 1/2 - Service ID: 123 - Version: 1 - Name: logs - Address: example.com:123 - Port: 123 - Format: %h %l %u %t "%r" %>s %b - Format version: 2 - Response condition: Prevent default logging - Placement: none - Papertrail 2/2 - Service ID: 123 - Version: 1 - Name: analytics - Address: 127.0.0.1:456 - Port: 456 - Format: %h %l %u %t "%r" %>s %b - Format version: 2 - Response condition: Prevent default logging - Placement: none -`) + "\n\n" - -func getPapertrailOK(i *fastly.GetPapertrailInput) (*fastly.Papertrail, error) { - return &fastly.Papertrail{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "logs", - Address: "example.com:123", - Port: 123, - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - ResponseCondition: "Prevent default logging", - Placement: "none", - }, nil -} - -func getPapertrailError(i *fastly.GetPapertrailInput) (*fastly.Papertrail, error) { - return nil, errTest -} - -var describePapertrailOutput = strings.TrimSpace(` -Service ID: 123 -Version: 1 -Name: logs -Address: example.com:123 -Port: 123 -Format: %h %l %u %t "%r" %>s %b -Format version: 2 -Response condition: Prevent default logging -Placement: none -`) + "\n" - -func updatePapertrailOK(i *fastly.UpdatePapertrailInput) (*fastly.Papertrail, error) { - return &fastly.Papertrail{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "log", - Address: "example.com:123", - Port: 123, - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - ResponseCondition: "Prevent default logging", - Placement: "none", - }, nil -} - -func updatePapertrailError(i *fastly.UpdatePapertrailInput) (*fastly.Papertrail, error) { - return nil, errTest -} - -func deletePapertrailOK(i *fastly.DeletePapertrailInput) error { - return nil -} - -func deletePapertrailError(i *fastly.DeletePapertrailInput) error { - return errTest -} diff --git a/pkg/logging/papertrail/papertrail_test.go b/pkg/logging/papertrail/papertrail_test.go deleted file mode 100644 index bf0e4f142..000000000 --- a/pkg/logging/papertrail/papertrail_test.go +++ /dev/null @@ -1,186 +0,0 @@ -package papertrail - -import ( - "testing" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/mock" - "github.com/fastly/cli/pkg/testutil" - "github.com/fastly/go-fastly/v3/fastly" -) - -func TestCreatePapertrailInput(t *testing.T) { - for _, testcase := range []struct { - name string - cmd *CreateCommand - want *fastly.CreatePapertrailInput - wantError string - }{ - { - name: "required values set flag serviceID", - cmd: createCommandRequired(), - want: &fastly.CreatePapertrailInput{ - ServiceID: "123", - ServiceVersion: 2, - Name: "log", - Address: "example.com", - }, - }, - { - name: "all values set flag serviceID", - cmd: createCommandAll(), - want: &fastly.CreatePapertrailInput{ - ServiceID: "123", - ServiceVersion: 2, - Name: "log", - Address: "example.com", - Port: 22, - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - ResponseCondition: "Prevent default logging", - Placement: "none", - }, - }, - { - name: "error missing serviceID", - cmd: createCommandMissingServiceID(), - want: nil, - wantError: errors.ErrNoServiceID.Error(), - }, - } { - t.Run(testcase.name, func(t *testing.T) { - have, err := testcase.cmd.createInput() - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertEqual(t, testcase.want, have) - }) - } -} - -func TestUpdatePapertrailInput(t *testing.T) { - for _, testcase := range []struct { - name string - cmd *UpdateCommand - api mock.API - want *fastly.UpdatePapertrailInput - wantError string - }{ - { - name: "no updates", - cmd: updateCommandNoUpdates(), - api: mock.API{GetPapertrailFn: getPapertrailOK}, - want: &fastly.UpdatePapertrailInput{ - ServiceID: "123", - ServiceVersion: 2, - Name: "log", - }, - }, - { - name: "all values set flag serviceID", - cmd: updateCommandAll(), - api: mock.API{GetPapertrailFn: getPapertrailOK}, - want: &fastly.UpdatePapertrailInput{ - ServiceID: "123", - ServiceVersion: 2, - Name: "log", - NewName: fastly.String("new1"), - Address: fastly.String("new2"), - Port: fastly.Uint(23), - Format: fastly.String("new3"), - FormatVersion: fastly.Uint(3), - ResponseCondition: fastly.String("new4"), - Placement: fastly.String("new5"), - }, - }, - { - name: "error missing serviceID", - cmd: updateCommandMissingServiceID(), - want: nil, - wantError: errors.ErrNoServiceID.Error(), - }, - } { - t.Run(testcase.name, func(t *testing.T) { - testcase.cmd.Base.Globals.Client = testcase.api - - have, err := testcase.cmd.createInput() - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertEqual(t, testcase.want, have) - }) - } -} - -func createCommandRequired() *CreateCommand { - return &CreateCommand{ - manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, - EndpointName: "log", - Address: "example.com", - Version: 2, - } -} - -func createCommandAll() *CreateCommand { - return &CreateCommand{ - manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, - EndpointName: "log", - Version: 2, - Address: "example.com", - Format: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: `%h %l %u %t "%r" %>s %b`}, - FormatVersion: common.OptionalUint{Optional: common.Optional{WasSet: true}, Value: 2}, - ResponseCondition: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "Prevent default logging"}, - Placement: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "none"}, - Port: common.OptionalUint{Optional: common.Optional{WasSet: true}, Value: 22}, - } -} - -func createCommandMissingServiceID() *CreateCommand { - res := createCommandAll() - res.manifest = manifest.Data{} - return res -} - -func updateCommandNoUpdates() *UpdateCommand { - return &UpdateCommand{ - Base: common.Base{Globals: &config.Data{Client: nil}}, - manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, - EndpointName: "log", - Version: 2, - } -} - -func updateCommandAll() *UpdateCommand { - return &UpdateCommand{ - Base: common.Base{Globals: &config.Data{Client: nil}}, - manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, - EndpointName: "log", - Version: 2, - NewName: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new1"}, - Address: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new2"}, - Port: common.OptionalUint{Optional: common.Optional{WasSet: true}, Value: 23}, - Format: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new3"}, - FormatVersion: common.OptionalUint{Optional: common.Optional{WasSet: true}, Value: 3}, - ResponseCondition: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new4"}, - Placement: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new5"}, - } -} - -func updateCommandMissingServiceID() *UpdateCommand { - res := updateCommandAll() - res.manifest = manifest.Data{} - return res -} - -func getPapertrailOK(i *fastly.GetPapertrailInput) (*fastly.Papertrail, error) { - return &fastly.Papertrail{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "logs", - Address: "example.com", - Port: 22, - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - ResponseCondition: "Prevent default logging", - Placement: "none", - }, nil -} diff --git a/pkg/logging/papertrail/root.go b/pkg/logging/papertrail/root.go deleted file mode 100644 index 56022eb51..000000000 --- a/pkg/logging/papertrail/root.go +++ /dev/null @@ -1,28 +0,0 @@ -package papertrail - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/config" -) - -// RootCommand is the parent command for all subcommands in this package. -// It should be installed under the primary root command. -type RootCommand struct { - common.Base - // no flags -} - -// NewRootCommand returns a new command registered in the parent. -func NewRootCommand(parent common.Registerer, globals *config.Data) *RootCommand { - var c RootCommand - c.Globals = globals - c.CmdClause = parent.Command("papertrail", "Manipulate Fastly service version Papertrail logging endpoints.") - return &c -} - -// Exec implements the command interface. -func (c *RootCommand) Exec(in io.Reader, out io.Writer) error { - panic("unreachable") -} diff --git a/pkg/logging/papertrail/update.go b/pkg/logging/papertrail/update.go deleted file mode 100644 index 649ef2729..000000000 --- a/pkg/logging/papertrail/update.go +++ /dev/null @@ -1,120 +0,0 @@ -package papertrail - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// UpdateCommand calls the Fastly API to update a Papertrail logging endpoint. -type UpdateCommand struct { - common.Base - manifest manifest.Data - - // required - EndpointName string - Version int - - // optional - NewName common.OptionalString - Address common.OptionalString - Port common.OptionalUint - FormatVersion common.OptionalUint - Format common.OptionalString - ResponseCondition common.OptionalString - Placement common.OptionalString -} - -// NewUpdateCommand returns a usable command registered under the parent. -func NewUpdateCommand(parent common.Registerer, globals *config.Data) *UpdateCommand { - var c UpdateCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - - c.CmdClause = parent.Command("update", "Update a Papertrail logging endpoint on a Fastly service version") - - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Version) - c.CmdClause.Flag("name", "The name of the Papertrail logging object").Short('n').Required().StringVar(&c.EndpointName) - - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("new-name", "New name of the Papertrail logging object").Action(c.NewName.Set).StringVar(&c.NewName.Value) - c.CmdClause.Flag("address", "A hostname or IPv4 address").Action(c.Address.Set).StringVar(&c.Address.Value) - c.CmdClause.Flag("port", "The port number").Action(c.Port.Set).UintVar(&c.Port.Value) - c.CmdClause.Flag("format-version", "The version of the custom logging format used for the configured endpoint. Can be either 2 (the default, version 2 log format) or 1 (the version 1 log format). The logging call gets placed by default in vcl_log if format_version is set to 2 and in vcl_deliver if format_version is set to 1").Action(c.FormatVersion.Set).UintVar(&c.FormatVersion.Value) - c.CmdClause.Flag("format", "Apache style log formatting").Action(c.Format.Set).StringVar(&c.Format.Value) - c.CmdClause.Flag("response-condition", "The name of an existing condition in the configured endpoint, or leave blank to always execute").Action(c.ResponseCondition.Set).StringVar(&c.ResponseCondition.Value) - c.CmdClause.Flag("placement", "Where in the generated VCL the logging call should be placed, overriding any format_version default. Can be none or waf_debug. This field is not required and has no default value").Action(c.Placement.Set).StringVar(&c.Placement.Value) - - return &c -} - -// createInput transforms values parsed from CLI flags into an object to be used by the API client library. -func (c *UpdateCommand) createInput() (*fastly.UpdatePapertrailInput, error) { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return nil, errors.ErrNoServiceID - } - - input := fastly.UpdatePapertrailInput{ - ServiceID: serviceID, - ServiceVersion: c.Version, - Name: c.EndpointName, - } - - // Set new values if set by user. - if c.NewName.WasSet { - input.NewName = fastly.String(c.NewName.Value) - } - - if c.NewName.WasSet { - input.NewName = fastly.String(c.NewName.Value) - } - - if c.Address.WasSet { - input.Address = fastly.String(c.Address.Value) - } - - if c.Port.WasSet { - input.Port = fastly.Uint(c.Port.Value) - } - - if c.FormatVersion.WasSet { - input.FormatVersion = fastly.Uint(c.FormatVersion.Value) - } - - if c.Format.WasSet { - input.Format = fastly.String(c.Format.Value) - } - - if c.ResponseCondition.WasSet { - input.ResponseCondition = fastly.String(c.ResponseCondition.Value) - } - - if c.Placement.WasSet { - input.Placement = fastly.String(c.Placement.Value) - } - - return &input, nil -} - -// Exec invokes the application logic for the command. -func (c *UpdateCommand) Exec(in io.Reader, out io.Writer) error { - input, err := c.createInput() - if err != nil { - return err - } - - papertrail, err := c.Globals.Client.UpdatePapertrail(input) - if err != nil { - return err - } - - text.Success(out, "Updated Papertrail logging endpoint %s (service %s version %d)", papertrail.Name, papertrail.ServiceID, papertrail.ServiceVersion) - return nil -} diff --git a/pkg/logging/root.go b/pkg/logging/root.go deleted file mode 100644 index 5fb661832..000000000 --- a/pkg/logging/root.go +++ /dev/null @@ -1,28 +0,0 @@ -package logging - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/config" -) - -// RootCommand is the parent command for all subcommands in this package. -// It should be installed under the primary root command. -type RootCommand struct { - common.Base - // no flags -} - -// NewRootCommand returns a new command registered in the parent. -func NewRootCommand(parent common.Registerer, globals *config.Data) *RootCommand { - var c RootCommand - c.Globals = globals - c.CmdClause = parent.Command("logging", "Manipulate Fastly service version logging endpoints") - return &c -} - -// Exec implements the command interface. -func (c *RootCommand) Exec(in io.Reader, out io.Writer) error { - panic("unreachable") -} diff --git a/pkg/logging/s3/create.go b/pkg/logging/s3/create.go deleted file mode 100644 index 02b683995..000000000 --- a/pkg/logging/s3/create.go +++ /dev/null @@ -1,223 +0,0 @@ -package s3 - -import ( - "fmt" - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// CreateCommand calls the Fastly API to create an Amazon S3 logging endpoint. -type CreateCommand struct { - common.Base - manifest manifest.Data - - // required - EndpointName string // Can't shadow common.Base method Name(). - Version int - BucketName string - - // mutual exclusions - // AccessKey + SecretKey or IAMRole must be provided - AccessKey common.OptionalString - SecretKey common.OptionalString - IAMRole common.OptionalString - - // optional - Domain common.OptionalString - Path common.OptionalString - Period common.OptionalUint - GzipLevel common.OptionalUint - Format common.OptionalString - FormatVersion common.OptionalUint - MessageType common.OptionalString - ResponseCondition common.OptionalString - TimestampFormat common.OptionalString - Placement common.OptionalString - Redundancy common.OptionalString - PublicKey common.OptionalString - ServerSideEncryption common.OptionalString - ServerSideEncryptionKMSKeyID common.OptionalString - CompressionCodec common.OptionalString -} - -// NewCreateCommand returns a usable command registered under the parent. -func NewCreateCommand(parent common.Registerer, globals *config.Data) *CreateCommand { - var c CreateCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("create", "Create an Amazon S3 logging endpoint on a Fastly service version").Alias("add") - - c.CmdClause.Flag("name", "The name of the S3 logging object. Used as a primary key for API access").Short('n').Required().StringVar(&c.EndpointName) - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Version) - c.CmdClause.Flag("bucket", "Your S3 bucket name").Required().StringVar(&c.BucketName) - - c.CmdClause.Flag("access-key", "Your S3 account access key").Action(c.AccessKey.Set).StringVar(&c.AccessKey.Value) - c.CmdClause.Flag("secret-key", "Your S3 account secret key").Action(c.SecretKey.Set).StringVar(&c.SecretKey.Value) - c.CmdClause.Flag("iam-role", "The IAM role ARN for logging").Action(c.IAMRole.Set).StringVar(&c.IAMRole.Value) - - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("domain", "The domain of the S3 endpoint").Action(c.Domain.Set).StringVar(&c.Domain.Value) - c.CmdClause.Flag("path", "The path to upload logs to").Action(c.Path.Set).StringVar(&c.Path.Value) - c.CmdClause.Flag("period", "How frequently log files are finalized so they can be available for reading (in seconds, default 3600)").Action(c.Period.Set).UintVar(&c.Period.Value) - c.CmdClause.Flag("gzip-level", "What level of GZIP encoding to have when dumping logs (default 0, no compression)").Action(c.GzipLevel.Set).UintVar(&c.GzipLevel.Value) - c.CmdClause.Flag("format", "Apache style log formatting").Action(c.Format.Set).StringVar(&c.Format.Value) - c.CmdClause.Flag("format-version", "The version of the custom logging format used for the configured endpoint. Can be either 2 (default) or 1").Action(c.FormatVersion.Set).UintVar(&c.FormatVersion.Value) - c.CmdClause.Flag("message-type", "How the message should be formatted. One of: classic (default), loggly, logplex or blank").Action(c.MessageType.Set).StringVar(&c.MessageType.Value) - c.CmdClause.Flag("response-condition", "The name of an existing condition in the configured endpoint, or leave blank to always execute").Action(c.ResponseCondition.Set).StringVar(&c.ResponseCondition.Value) - c.CmdClause.Flag("timestamp-format", `strftime specified timestamp formatting (default "%Y-%m-%dT%H:%M:%S.000")`).Action(c.TimestampFormat.Set).StringVar(&c.TimestampFormat.Value) - c.CmdClause.Flag("redundancy", "The S3 redundancy level. Can be either standard or reduced_redundancy").Action(c.Redundancy.Set).EnumVar(&c.Redundancy.Value, string(fastly.S3RedundancyStandard), string(fastly.S3RedundancyReduced)) - c.CmdClause.Flag("placement", "Where in the generated VCL the logging call should be placed, overriding any format_version default. Can be none or waf_debug").Action(c.Placement.Set).StringVar(&c.Placement.Value) - c.CmdClause.Flag("public-key", "A PGP public key that Fastly will use to encrypt your log files before writing them to disk").Action(c.PublicKey.Set).StringVar(&c.PublicKey.Value) - c.CmdClause.Flag("server-side-encryption", "Set to enable S3 Server Side Encryption. Can be either AES256 or aws:kms").Action(c.ServerSideEncryption.Set).EnumVar(&c.ServerSideEncryption.Value, string(fastly.S3ServerSideEncryptionAES), string(fastly.S3ServerSideEncryptionKMS)) - c.CmdClause.Flag("server-side-encryption-kms-key-id", "Server-side KMS Key ID. Must be set if server-side-encryption is set to aws:kms").Action(c.ServerSideEncryptionKMSKeyID.Set).StringVar(&c.ServerSideEncryptionKMSKeyID.Value) - c.CmdClause.Flag("compression-codec", `The codec used for compression of your logs. Valid values are zstd, snappy, and gzip. If the specified codec is "gzip", gzip_level will default to 3. To specify a different level, leave compression_codec blank and explicitly set the level using gzip_level. Specifying both compression_codec and gzip_level in the same API request will result in an error.`).Action(c.CompressionCodec.Set).StringVar(&c.CompressionCodec.Value) - - return &c -} - -// createInput transforms values parsed from CLI flags into an object to be used by the API client library. -func (c *CreateCommand) createInput() (*fastly.CreateS3Input, error) { - var input fastly.CreateS3Input - - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return nil, errors.ErrNoServiceID - } - - input.ServiceID = serviceID - input.ServiceVersion = c.Version - input.Name = c.EndpointName - input.BucketName = c.BucketName - - // The following block checks for invalid permutations of the ways in - // which the AccessKey + SecretKey and IAMRole flags can be - // provided. This is necessary because either the AccessKey and - // SecretKey or the IAMRole is required, but they are mutually - // exclusive. The kingpin library lacks a way to express this constraint - // via the flag specification API so we enforce it manually here. - if !c.AccessKey.WasSet && !c.SecretKey.WasSet && !c.IAMRole.WasSet { - return nil, fmt.Errorf("error parsing arguments: the --access-key and --secret-key flags or the --iam-role flag must be provided") - } else if (c.AccessKey.WasSet || c.SecretKey.WasSet) && c.IAMRole.WasSet { - // Enforce mutual exclusion - return nil, fmt.Errorf("error parsing arguments: the --access-key and --secret-key flags are mutually exclusive with the --iam-role flag") - } else if c.AccessKey.WasSet && !c.SecretKey.WasSet { - return nil, fmt.Errorf("error parsing arguments: required flag --secret-key not provided") - - } else if !c.AccessKey.WasSet && c.SecretKey.WasSet { - return nil, fmt.Errorf("error parsing arguments: required flag --access-key not provided") - - } - - // The following blocks enforces the mutual exclusivity of the - // CompressionCodec and GzipLevel flags. - if c.CompressionCodec.WasSet && c.GzipLevel.WasSet { - return nil, fmt.Errorf("error parsing arguments: the --compression-codec flag is mutually exclusive with the --gzip-level flag") - } - - if c.AccessKey.WasSet { - input.AccessKey = c.AccessKey.Value - } - - if c.SecretKey.WasSet { - input.SecretKey = c.SecretKey.Value - } - - if c.IAMRole.WasSet { - input.IAMRole = c.IAMRole.Value - } - - if c.Domain.WasSet { - input.Domain = c.Domain.Value - } - - if c.Path.WasSet { - input.Path = c.Path.Value - } - - if c.Period.WasSet { - input.Period = c.Period.Value - } - - if c.GzipLevel.WasSet { - input.GzipLevel = c.GzipLevel.Value - } - - if c.Format.WasSet { - input.Format = c.Format.Value - } - - if c.FormatVersion.WasSet { - input.FormatVersion = c.FormatVersion.Value - } - - if c.MessageType.WasSet { - input.MessageType = c.MessageType.Value - } - - if c.ResponseCondition.WasSet { - input.ResponseCondition = c.ResponseCondition.Value - } - - if c.TimestampFormat.WasSet { - input.TimestampFormat = c.TimestampFormat.Value - } - - if c.Placement.WasSet { - input.Placement = c.Placement.Value - } - - if c.PublicKey.WasSet { - input.PublicKey = c.PublicKey.Value - } - - if c.ServerSideEncryptionKMSKeyID.WasSet { - input.ServerSideEncryptionKMSKeyID = c.ServerSideEncryptionKMSKeyID.Value - } - - if c.CompressionCodec.WasSet { - input.CompressionCodec = c.CompressionCodec.Value - } - - if c.Redundancy.WasSet { - switch c.Redundancy.Value { - case string(fastly.S3RedundancyStandard): - input.Redundancy = fastly.S3RedundancyStandard - case string(fastly.S3RedundancyReduced): - input.Redundancy = fastly.S3RedundancyReduced - } - } - - if c.ServerSideEncryption.WasSet { - switch c.ServerSideEncryption.Value { - case string(fastly.S3ServerSideEncryptionAES): - input.ServerSideEncryption = fastly.S3ServerSideEncryptionAES - case string(fastly.S3ServerSideEncryptionKMS): - input.ServerSideEncryption = fastly.S3ServerSideEncryptionKMS - } - } - - return &input, nil -} - -// Exec invokes the application logic for the command. -func (c *CreateCommand) Exec(in io.Reader, out io.Writer) error { - input, err := c.createInput() - if err != nil { - return err - } - - d, err := c.Globals.Client.CreateS3(input) - if err != nil { - return err - } - - text.Success(out, "Created S3 logging endpoint %s (service %s version %d)", d.Name, d.ServiceID, d.ServiceVersion) - return nil -} diff --git a/pkg/logging/s3/delete.go b/pkg/logging/s3/delete.go deleted file mode 100644 index 61827e223..000000000 --- a/pkg/logging/s3/delete.go +++ /dev/null @@ -1,50 +0,0 @@ -package s3 - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// DeleteCommand calls the Fastly API to delete an Amazon S3 logging endpoint. -type DeleteCommand struct { - common.Base - manifest manifest.Data - Input fastly.DeleteS3Input -} - -// NewDeleteCommand returns a usable command registered under the parent. -func NewDeleteCommand(parent common.Registerer, globals *config.Data) *DeleteCommand { - var c DeleteCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("delete", "Delete a S3 logging endpoint on a Fastly service version").Alias("remove") - - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Input.ServiceVersion) - c.CmdClause.Flag("name", "The name of the S3 logging object").Short('n').Required().StringVar(&c.Input.Name) - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - - return &c -} - -// Exec invokes the application logic for the command. -func (c *DeleteCommand) Exec(in io.Reader, out io.Writer) error { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - c.Input.ServiceID = serviceID - - if err := c.Globals.Client.DeleteS3(&c.Input); err != nil { - return err - } - - text.Success(out, "Deleted S3 logging endpoint %s (service %s version %d)", c.Input.Name, c.Input.ServiceID, c.Input.ServiceVersion) - return nil -} diff --git a/pkg/logging/s3/describe.go b/pkg/logging/s3/describe.go deleted file mode 100644 index 42afa3683..000000000 --- a/pkg/logging/s3/describe.go +++ /dev/null @@ -1,74 +0,0 @@ -package s3 - -import ( - "fmt" - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/go-fastly/v3/fastly" -) - -// DescribeCommand calls the Fastly API to describe an Amazon S3 logging endpoint. -type DescribeCommand struct { - common.Base - manifest manifest.Data - Input fastly.GetS3Input -} - -// NewDescribeCommand returns a usable command registered under the parent. -func NewDescribeCommand(parent common.Registerer, globals *config.Data) *DescribeCommand { - var c DescribeCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("describe", "Show detailed information about a S3 logging endpoint on a Fastly service version").Alias("get") - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Input.ServiceVersion) - c.CmdClause.Flag("name", "The name of the S3 logging object").Short('n').Required().StringVar(&c.Input.Name) - return &c -} - -// Exec invokes the application logic for the command. -func (c *DescribeCommand) Exec(in io.Reader, out io.Writer) error { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - c.Input.ServiceID = serviceID - - s3, err := c.Globals.Client.GetS3(&c.Input) - if err != nil { - return err - } - - fmt.Fprintf(out, "Service ID: %s\n", s3.ServiceID) - fmt.Fprintf(out, "Version: %d\n", s3.ServiceVersion) - fmt.Fprintf(out, "Name: %s\n", s3.Name) - fmt.Fprintf(out, "Bucket: %s\n", s3.BucketName) - if s3.AccessKey != "" || s3.SecretKey != "" { - fmt.Fprintf(out, "Access key: %s\n", s3.AccessKey) - fmt.Fprintf(out, "Secret key: %s\n", s3.SecretKey) - } - if s3.IAMRole != "" { - fmt.Fprintf(out, "IAM role: %s\n", s3.IAMRole) - } - fmt.Fprintf(out, "Path: %s\n", s3.Path) - fmt.Fprintf(out, "Period: %d\n", s3.Period) - fmt.Fprintf(out, "GZip level: %d\n", s3.GzipLevel) - fmt.Fprintf(out, "Format: %s\n", s3.Format) - fmt.Fprintf(out, "Format version: %d\n", s3.FormatVersion) - fmt.Fprintf(out, "Response condition: %s\n", s3.ResponseCondition) - fmt.Fprintf(out, "Message type: %s\n", s3.MessageType) - fmt.Fprintf(out, "Timestamp format: %s\n", s3.TimestampFormat) - fmt.Fprintf(out, "Placement: %s\n", s3.Placement) - fmt.Fprintf(out, "Public key: %s\n", s3.PublicKey) - fmt.Fprintf(out, "Redundancy: %s\n", s3.Redundancy) - fmt.Fprintf(out, "Server-side encryption: %s\n", s3.ServerSideEncryption) - fmt.Fprintf(out, "Server-side encryption KMS key ID: %s\n", s3.ServerSideEncryption) - fmt.Fprintf(out, "Compression codec: %s\n", s3.CompressionCodec) - - return nil -} diff --git a/pkg/logging/s3/list.go b/pkg/logging/s3/list.go deleted file mode 100644 index 5e94b5451..000000000 --- a/pkg/logging/s3/list.go +++ /dev/null @@ -1,90 +0,0 @@ -package s3 - -import ( - "fmt" - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// ListCommand calls the Fastly API to list Amazon S3 logging endpoints. -type ListCommand struct { - common.Base - manifest manifest.Data - Input fastly.ListS3sInput -} - -// NewListCommand returns a usable command registered under the parent. -func NewListCommand(parent common.Registerer, globals *config.Data) *ListCommand { - var c ListCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("list", "List S3 endpoints on a Fastly service version") - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Input.ServiceVersion) - return &c -} - -// Exec invokes the application logic for the command. -func (c *ListCommand) Exec(in io.Reader, out io.Writer) error { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - c.Input.ServiceID = serviceID - - s3s, err := c.Globals.Client.ListS3s(&c.Input) - if err != nil { - return err - } - - if !c.Globals.Verbose() { - tw := text.NewTable(out) - tw.AddHeader("SERVICE", "VERSION", "NAME") - for _, s3 := range s3s { - tw.AddLine(s3.ServiceID, s3.ServiceVersion, s3.Name) - } - tw.Print() - return nil - } - - fmt.Fprintf(out, "Service ID: %s\n", c.Input.ServiceID) - fmt.Fprintf(out, "Version: %d\n", c.Input.ServiceVersion) - for i, s3 := range s3s { - fmt.Fprintf(out, "\tS3 %d/%d\n", i+1, len(s3s)) - fmt.Fprintf(out, "\t\tService ID: %s\n", s3.ServiceID) - fmt.Fprintf(out, "\t\tVersion: %d\n", s3.ServiceVersion) - fmt.Fprintf(out, "\t\tName: %s\n", s3.Name) - fmt.Fprintf(out, "\t\tBucket: %s\n", s3.BucketName) - if s3.AccessKey != "" || s3.SecretKey != "" { - fmt.Fprintf(out, "\t\tAccess key: %s\n", s3.AccessKey) - fmt.Fprintf(out, "\t\tSecret key: %s\n", s3.SecretKey) - } - if s3.IAMRole != "" { - fmt.Fprintf(out, "\t\tIAM role: %s\n", s3.IAMRole) - } - fmt.Fprintf(out, "\t\tPath: %s\n", s3.Path) - fmt.Fprintf(out, "\t\tPeriod: %d\n", s3.Period) - fmt.Fprintf(out, "\t\tGZip level: %d\n", s3.GzipLevel) - fmt.Fprintf(out, "\t\tFormat: %s\n", s3.Format) - fmt.Fprintf(out, "\t\tFormat version: %d\n", s3.FormatVersion) - fmt.Fprintf(out, "\t\tResponse condition: %s\n", s3.ResponseCondition) - fmt.Fprintf(out, "\t\tMessage type: %s\n", s3.MessageType) - fmt.Fprintf(out, "\t\tTimestamp format: %s\n", s3.TimestampFormat) - fmt.Fprintf(out, "\t\tPlacement: %s\n", s3.Placement) - fmt.Fprintf(out, "\t\tPublic key: %s\n", s3.PublicKey) - fmt.Fprintf(out, "\t\tRedundancy: %s\n", s3.Redundancy) - fmt.Fprintf(out, "\t\tServer-side encryption: %s\n", s3.ServerSideEncryption) - fmt.Fprintf(out, "\t\tServer-side encryption KMS key ID: %s\n", s3.ServerSideEncryption) - fmt.Fprintf(out, "\t\tCompression codec: %s\n", s3.CompressionCodec) - } - fmt.Fprintln(out) - - return nil -} diff --git a/pkg/logging/s3/root.go b/pkg/logging/s3/root.go deleted file mode 100644 index b07161a51..000000000 --- a/pkg/logging/s3/root.go +++ /dev/null @@ -1,28 +0,0 @@ -package s3 - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/config" -) - -// RootCommand is the parent command for all subcommands in this package. -// It should be installed under the primary root command. -type RootCommand struct { - common.Base - // no flags -} - -// NewRootCommand returns a new command registered in the parent. -func NewRootCommand(parent common.Registerer, globals *config.Data) *RootCommand { - var c RootCommand - c.Globals = globals - c.CmdClause = parent.Command("s3", "Manipulate Fastly service version S3 logging endpoints") - return &c -} - -// Exec implements the command interface. -func (c *RootCommand) Exec(in io.Reader, out io.Writer) error { - panic("unreachable") -} diff --git a/pkg/logging/s3/s3_integration_test.go b/pkg/logging/s3/s3_integration_test.go deleted file mode 100644 index 4f7045f00..000000000 --- a/pkg/logging/s3/s3_integration_test.go +++ /dev/null @@ -1,524 +0,0 @@ -package s3_test - -import ( - "bytes" - "errors" - "io" - "net/http" - "strings" - "testing" - - "github.com/fastly/cli/pkg/app" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/mock" - "github.com/fastly/cli/pkg/testutil" - "github.com/fastly/cli/pkg/update" - "github.com/fastly/go-fastly/v3/fastly" -) - -func TestS3Create(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "s3", "create", "--service-id", "123", "--version", "1", "--name", "log", "--bucket", "log"}, - wantError: "error parsing arguments: the --access-key and --secret-key flags or the --iam-role flag must be provided", - }, - { - args: []string{"logging", "s3", "create", "--service-id", "123", "--version", "1", "--name", "log", "--bucket", "log", "--access-key", "foo"}, - wantError: "error parsing arguments: required flag --secret-key not provided", - }, - { - args: []string{"logging", "s3", "create", "--service-id", "123", "--version", "1", "--name", "log", "--bucket", "log", "--secret-key", "bar"}, - wantError: "error parsing arguments: required flag --access-key not provided", - }, - { - args: []string{"logging", "s3", "create", "--service-id", "123", "--version", "1", "--name", "log", "--bucket", "log", "--secret-key", "bar", "--iam-role", "arn:aws:iam::123456789012:role/S3Access"}, - wantError: "error parsing arguments: the --access-key and --secret-key flags are mutually exclusive with the --iam-role flag", - }, - { - args: []string{"logging", "s3", "create", "--service-id", "123", "--version", "1", "--name", "log", "--bucket", "log", "--access-key", "foo", "--iam-role", "arn:aws:iam::123456789012:role/S3Access"}, - wantError: "error parsing arguments: the --access-key and --secret-key flags are mutually exclusive with the --iam-role flag", - }, - { - args: []string{"logging", "s3", "create", "--service-id", "123", "--version", "1", "--name", "log", "--bucket", "log", "--access-key", "foo", "--secret-key", "bar", "--iam-role", "arn:aws:iam::123456789012:role/S3Access"}, - wantError: "error parsing arguments: the --access-key and --secret-key flags are mutually exclusive with the --iam-role flag", - }, - { - args: []string{"logging", "s3", "create", "--service-id", "123", "--version", "1", "--name", "log", "--bucket", "log", "--access-key", "foo", "--secret-key", "bar"}, - api: mock.API{CreateS3Fn: createS3OK}, - wantOutput: "Created S3 logging endpoint log (service 123 version 1)", - }, - { - args: []string{"logging", "s3", "create", "--service-id", "123", "--version", "1", "--name", "log", "--bucket", "log", "--access-key", "foo", "--secret-key", "bar"}, - api: mock.API{CreateS3Fn: createS3Error}, - wantError: errTest.Error(), - }, - { - args: []string{"logging", "s3", "create", "--service-id", "123", "--version", "1", "--name", "log2", "--bucket", "log", "--iam-role", "arn:aws:iam::123456789012:role/S3Access"}, - api: mock.API{CreateS3Fn: createS3OK}, - wantOutput: "Created S3 logging endpoint log2 (service 123 version 1)", - }, - { - args: []string{"logging", "s3", "create", "--service-id", "123", "--version", "1", "--name", "log2", "--bucket", "log", "--iam-role", "arn:aws:iam::123456789012:role/S3Access"}, - api: mock.API{CreateS3Fn: createS3Error}, - wantError: errTest.Error(), - }, - { - args: []string{"logging", "s3", "create", "--service-id", "123", "--version", "1", "--name", "log", "--bucket", "log", "--iam-role", "arn:aws:iam::123456789012:role/S3Access", "--compression-codec", "zstd", "--gzip-level", "9"}, - wantError: "error parsing arguments: the --compression-codec flag is mutually exclusive with the --gzip-level flag", - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertStringContains(t, out.String(), testcase.wantOutput) - }) - } -} - -func TestS3List(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "s3", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListS3sFn: listS3sOK}, - wantOutput: listS3sShortOutput, - }, - { - args: []string{"logging", "s3", "list", "--service-id", "123", "--version", "1", "--verbose"}, - api: mock.API{ListS3sFn: listS3sOK}, - wantOutput: listS3sVerboseOutput, - }, - { - args: []string{"logging", "s3", "list", "--service-id", "123", "--version", "1", "-v"}, - api: mock.API{ListS3sFn: listS3sOK}, - wantOutput: listS3sVerboseOutput, - }, - { - args: []string{"logging", "s3", "--verbose", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListS3sFn: listS3sOK}, - wantOutput: listS3sVerboseOutput, - }, - { - args: []string{"logging", "-v", "s3", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListS3sFn: listS3sOK}, - wantOutput: listS3sVerboseOutput, - }, - { - args: []string{"logging", "s3", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListS3sFn: listS3sError}, - wantError: errTest.Error(), - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertString(t, testcase.wantOutput, out.String()) - }) - } -} - -func TestS3Describe(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "s3", "describe", "--service-id", "123", "--version", "1"}, - wantError: "error parsing arguments: required flag --name not provided", - }, - { - args: []string{"logging", "s3", "describe", "--service-id", "123", "--version", "1", "--name", "logs"}, - api: mock.API{GetS3Fn: getS3Error}, - wantError: errTest.Error(), - }, - { - args: []string{"logging", "s3", "describe", "--service-id", "123", "--version", "1", "--name", "logs"}, - api: mock.API{GetS3Fn: getS3OK}, - wantOutput: describeS3Output, - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertString(t, testcase.wantOutput, out.String()) - }) - } -} - -func TestS3Update(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "s3", "update", "--service-id", "123", "--version", "1", "--new-name", "log"}, - wantError: "error parsing arguments: required flag --name not provided", - }, - { - args: []string{"logging", "s3", "update", "--service-id", "123", "--version", "1", "--name", "logs", "--new-name", "log"}, - api: mock.API{UpdateS3Fn: updateS3Error}, - wantError: errTest.Error(), - }, - { - args: []string{"logging", "s3", "update", "--service-id", "123", "--version", "1", "--name", "logs", "--new-name", "log"}, - api: mock.API{UpdateS3Fn: updateS3OK}, - wantOutput: "Updated S3 logging endpoint log (service 123 version 1)", - }, - { - args: []string{"logging", "s3", "update", "--service-id", "123", "--version", "1", "--name", "logs", "--access-key", "foo", "--secret-key", "bar", "--iam-role", ""}, - api: mock.API{UpdateS3Fn: updateS3OK}, - wantOutput: "Updated S3 logging endpoint log (service 123 version 1)", - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertStringContains(t, out.String(), testcase.wantOutput) - }) - } -} - -func TestS3Delete(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "s3", "delete", "--service-id", "123", "--version", "1"}, - wantError: "error parsing arguments: required flag --name not provided", - }, - { - args: []string{"logging", "s3", "delete", "--service-id", "123", "--version", "1", "--name", "logs"}, - api: mock.API{DeleteS3Fn: deleteS3Error}, - wantError: errTest.Error(), - }, - { - args: []string{"logging", "s3", "delete", "--service-id", "123", "--version", "1", "--name", "logs"}, - api: mock.API{DeleteS3Fn: deleteS3OK}, - wantOutput: "Deleted S3 logging endpoint logs (service 123 version 1)", - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertStringContains(t, out.String(), testcase.wantOutput) - }) - } -} - -var errTest = errors.New("fixture error") - -func createS3OK(i *fastly.CreateS3Input) (*fastly.S3, error) { - return &fastly.S3{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: i.Name, - CompressionCodec: "zstd", - }, nil -} - -func createS3Error(i *fastly.CreateS3Input) (*fastly.S3, error) { - return nil, errTest -} - -func listS3sOK(i *fastly.ListS3sInput) ([]*fastly.S3, error) { - return []*fastly.S3{ - { - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "logs", - BucketName: "my-logs", - AccessKey: "1234", - SecretKey: "-----BEGIN RSA PRIVATE KEY-----MIIEogIBAAKCA", - IAMRole: "", - Domain: "https://s3.us-east-1.amazonaws.com", - Path: "logs/", - Period: 3600, - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - MessageType: "classic", - ResponseCondition: "Prevent default logging", - TimestampFormat: "%Y-%m-%dT%H:%M:%S.000", - Redundancy: "standard", - Placement: "none", - PublicKey: pgpPublicKey(), - ServerSideEncryption: "aws:kms", - ServerSideEncryptionKMSKeyID: "1234", - CompressionCodec: "zstd", - }, - { - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "analytics", - BucketName: "analytics", - AccessKey: "1234", - SecretKey: "-----BEGIN RSA PRIVATE KEY-----MIIEogIBAAKCA", - Domain: "https://s3.us-east-2.amazonaws.com", - Path: "logs/", - Period: 86400, - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - MessageType: "classic", - ResponseCondition: "Prevent default logging", - TimestampFormat: "%Y-%m-%dT%H:%M:%S.000", - Redundancy: "standard", - Placement: "none", - PublicKey: pgpPublicKey(), - ServerSideEncryption: "aws:kms", - ServerSideEncryptionKMSKeyID: "1234", - CompressionCodec: "zstd", - }, - }, nil -} - -func listS3sError(i *fastly.ListS3sInput) ([]*fastly.S3, error) { - return nil, errTest -} - -var listS3sShortOutput = strings.TrimSpace(` -SERVICE VERSION NAME -123 1 logs -123 1 analytics -`) + "\n" - -var listS3sVerboseOutput = strings.TrimSpace(` -Fastly API token not provided -Fastly API endpoint: https://api.fastly.com -Service ID: 123 -Version: 1 - S3 1/2 - Service ID: 123 - Version: 1 - Name: logs - Bucket: my-logs - Access key: 1234 - Secret key: -----BEGIN RSA PRIVATE KEY-----MIIEogIBAAKCA - Path: logs/ - Period: 3600 - GZip level: 0 - Format: %h %l %u %t "%r" %>s %b - Format version: 2 - Response condition: Prevent default logging - Message type: classic - Timestamp format: %Y-%m-%dT%H:%M:%S.000 - Placement: none - Public key: `+pgpPublicKey()+` - Redundancy: standard - Server-side encryption: aws:kms - Server-side encryption KMS key ID: aws:kms - Compression codec: zstd - S3 2/2 - Service ID: 123 - Version: 1 - Name: analytics - Bucket: analytics - Access key: 1234 - Secret key: -----BEGIN RSA PRIVATE KEY-----MIIEogIBAAKCA - Path: logs/ - Period: 86400 - GZip level: 0 - Format: %h %l %u %t "%r" %>s %b - Format version: 2 - Response condition: Prevent default logging - Message type: classic - Timestamp format: %Y-%m-%dT%H:%M:%S.000 - Placement: none - Public key: `+pgpPublicKey()+` - Redundancy: standard - Server-side encryption: aws:kms - Server-side encryption KMS key ID: aws:kms - Compression codec: zstd -`) + "\n\n" - -func getS3OK(i *fastly.GetS3Input) (*fastly.S3, error) { - return &fastly.S3{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "logs", - BucketName: "my-logs", - AccessKey: "1234", - SecretKey: "-----BEGIN RSA PRIVATE KEY-----MIIEogIBAAKCA", - Domain: "https://s3.us-east-1.amazonaws.com", - Path: "logs/", - Period: 3600, - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - MessageType: "classic", - ResponseCondition: "Prevent default logging", - TimestampFormat: "%Y-%m-%dT%H:%M:%S.000", - Redundancy: "standard", - Placement: "none", - PublicKey: pgpPublicKey(), - ServerSideEncryption: "aws:kms", - ServerSideEncryptionKMSKeyID: "1234", - CompressionCodec: "zstd", - }, nil -} - -func getS3Error(i *fastly.GetS3Input) (*fastly.S3, error) { - return nil, errTest -} - -var describeS3Output = strings.TrimSpace(` -Service ID: 123 -Version: 1 -Name: logs -Bucket: my-logs -Access key: 1234 -Secret key: -----BEGIN RSA PRIVATE KEY-----MIIEogIBAAKCA -Path: logs/ -Period: 3600 -GZip level: 0 -Format: %h %l %u %t "%r" %>s %b -Format version: 2 -Response condition: Prevent default logging -Message type: classic -Timestamp format: %Y-%m-%dT%H:%M:%S.000 -Placement: none -Public key: `+pgpPublicKey()+` -Redundancy: standard -Server-side encryption: aws:kms -Server-side encryption KMS key ID: aws:kms -Compression codec: zstd -`) + "\n" - -func updateS3OK(i *fastly.UpdateS3Input) (*fastly.S3, error) { - return &fastly.S3{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "log", - BucketName: "my-logs", - AccessKey: "1234", - SecretKey: "-----BEGIN RSA PRIVATE KEY-----MIIEogIBAAKCA", - Domain: "https://s3.us-east-1.amazonaws.com", - Path: "logs/", - Period: 3600, - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - MessageType: "classic", - ResponseCondition: "Prevent default logging", - TimestampFormat: "%Y-%m-%dT%H:%M:%S.000", - Redundancy: "standard", - Placement: "none", - PublicKey: pgpPublicKey(), - ServerSideEncryption: "aws:kms", - ServerSideEncryptionKMSKeyID: "1234", - CompressionCodec: "zstd", - }, nil -} - -func updateS3Error(i *fastly.UpdateS3Input) (*fastly.S3, error) { - return nil, errTest -} - -func deleteS3OK(i *fastly.DeleteS3Input) error { - return nil -} - -func deleteS3Error(i *fastly.DeleteS3Input) error { - return errTest -} - -// pgpPublicKey returns a PEM encoded PGP public key suitable for testing. -func pgpPublicKey() string { - return strings.TrimSpace(`-----BEGIN PGP PUBLIC KEY BLOCK----- -mQENBFyUD8sBCACyFnB39AuuTygseek+eA4fo0cgwva6/FSjnWq7riouQee8GgQ/ -ibXTRyv4iVlwI12GswvMTIy7zNvs1R54i0qvsLr+IZ4GVGJqs6ZJnvQcqe3xPoR4 -8AnBfw90o32r/LuHf6QCJXi+AEu35koNlNAvLJ2B+KACaNB7N0EeWmqpV/1V2k9p -lDYk+th7LcCuaFNGqKS/PrMnnMqR6VDLCjHhNx4KR79b0Twm/2qp6an3hyNRu8Gn -dwxpf1/BUu3JWf+LqkN4Y3mbOmSUL3MaJNvyQguUzTfS0P0uGuBDHrJCVkMZCzDB -89ag55jCPHyGeHBTd02gHMWzsg3WMBWvCsrzABEBAAG0JXRlcnJhZm9ybSAodGVz -dCkgPHRlc3RAdGVycmFmb3JtLmNvbT6JAU4EEwEIADgWIQSHYyc6Kj9l6HzQsau6 -vFFc9jxV/wUCXJQPywIbAwULCQgHAgYVCgkICwIEFgIDAQIeAQIXgAAKCRC6vFFc -9jxV/815CAClb32OxV7wG01yF97TzlyTl8TnvjMtoG29Mw4nSyg+mjM3b8N7iXm9 -OLX59fbDAWtBSldSZE22RXd3CvlFOG/EnKBXSjBtEqfyxYSnyOPkMPBYWGL/ApkX -SvPYJ4LKdvipYToKFh3y9kk2gk1DcDBDyaaHvR+3rv1u3aoy7/s2EltAfDS3ZQIq -7/cWTLJml/lleeB/Y6rPj8xqeCYhE5ahw9gsV/Mdqatl24V9Tks30iijx0Hhw+Gx -kATUikMGr2GDVqoIRga5kXI7CzYff4rkc0Twn47fMHHHe/KY9M2yVnMHUXmAZwbG -M1cMI/NH1DjevCKdGBLcRJlhuLPKF/anuQENBFyUD8sBCADIpd7r7GuPd6n/Ikxe -u6h7umV6IIPoAm88xCYpTbSZiaK30Svh6Ywra9jfE2KlU9o6Y/art8ip0VJ3m07L -4RSfSpnzqgSwdjSq5hNour2Fo/BzYhK7yaz2AzVSbe33R0+RYhb4b/6N+bKbjwGF -ftCsqVFMH+PyvYkLbvxyQrHlA9woAZaNThI1ztO5rGSnGUR8xt84eup28WIFKg0K -UEGUcTzz+8QGAwAra+0ewPXo/AkO+8BvZjDidP417u6gpBHOJ9qYIcO9FxHeqFyu -YrjlrxowEgXn5wO8xuNz6Vu1vhHGDHGDsRbZF8pv1d5O+0F1G7ttZ2GRRgVBZPwi -kiyRABEBAAGJATYEGAEIACAWIQSHYyc6Kj9l6HzQsau6vFFc9jxV/wUCXJQPywIb -DAAKCRC6vFFc9jxV/9YOCACe8qmOSnKQpQfW+PqYOqo3dt7JyweTs3FkD6NT8Zml -dYy/vkstbTjPpX6aTvUZjkb46BVi7AOneVHpD5GBqvRsZ9iVgDYHaehmLCdKiG5L -3Tp90NN+QY5WDbsGmsyk6+6ZMYejb4qYfweQeduOj27aavCJdLkCYMoRKfcFYI8c -FaNmEfKKy/r1PO20NXEG6t9t05K/frHy6ZG8bCNYdpagfFVot47r9JaQqWlTNtIR -5+zkkSq/eG9BEtRij3a6cTdQbktdBzx2KBeI0PYc1vlZR0LpuFKZqY9vlE6vTGLR -wMfrTEOvx0NxUM3rpaCgEmuWbB1G1Hu371oyr4srrr+N -=28dr ------END PGP PUBLIC KEY BLOCK----- -`) -} diff --git a/pkg/logging/s3/s3_test.go b/pkg/logging/s3/s3_test.go deleted file mode 100644 index f0681c226..000000000 --- a/pkg/logging/s3/s3_test.go +++ /dev/null @@ -1,305 +0,0 @@ -package s3 - -import ( - "strings" - "testing" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/mock" - "github.com/fastly/cli/pkg/testutil" - "github.com/fastly/go-fastly/v3/fastly" -) - -func TestCreateS3Input(t *testing.T) { - for _, testcase := range []struct { - name string - cmd *CreateCommand - want *fastly.CreateS3Input - wantError string - }{ - { - name: "required values set flag serviceID using access credentials", - cmd: createCommandRequired(), - want: &fastly.CreateS3Input{ - ServiceID: "123", - ServiceVersion: 2, - Name: "log", - BucketName: "bucket", - AccessKey: "access", - SecretKey: "secret", - }, - }, - { - name: "required values set flag serviceID using IAM role", - cmd: createCommandRequiredIAMRole(), - want: &fastly.CreateS3Input{ - ServiceID: "123", - ServiceVersion: 2, - Name: "log", - BucketName: "bucket", - IAMRole: "arn:aws:iam::123456789012:role/S3Access", - }, - }, - { - name: "all values set flag serviceID", - cmd: createCommandAll(), - want: &fastly.CreateS3Input{ - ServiceID: "123", - ServiceVersion: 2, - Name: "logs", - BucketName: "bucket", - Domain: "domain", - AccessKey: "access", - SecretKey: "secret", - Path: "path", - Period: 3600, - Format: `%h %l %u %t "%r" %>s %b`, - MessageType: "classic", - FormatVersion: 2, - ResponseCondition: "Prevent default logging", - TimestampFormat: "%Y-%m-%dT%H:%M:%S.000", - Redundancy: fastly.S3RedundancyStandard, - Placement: "none", - PublicKey: pgpPublicKey(), - ServerSideEncryptionKMSKeyID: "kmskey", - ServerSideEncryption: fastly.S3ServerSideEncryptionAES, - CompressionCodec: "zstd", - }, - }, - { - name: "error missing serviceID", - cmd: createCommandMissingServiceID(), - want: nil, - wantError: errors.ErrNoServiceID.Error(), - }, - } { - t.Run(testcase.name, func(t *testing.T) { - have, err := testcase.cmd.createInput() - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertEqual(t, testcase.want, have) - }) - } -} - -func TestUpdateS3Input(t *testing.T) { - for _, testcase := range []struct { - name string - cmd *UpdateCommand - api mock.API - want *fastly.UpdateS3Input - wantError string - }{ - { - name: "no updates", - cmd: updateCommandNoUpdates(), - api: mock.API{GetS3Fn: getS3OK}, - want: &fastly.UpdateS3Input{ - ServiceID: "123", - ServiceVersion: 2, - Name: "log", - }, - }, - { - name: "all values set flag serviceID", - cmd: updateCommandAll(), - api: mock.API{GetS3Fn: getS3OK}, - want: &fastly.UpdateS3Input{ - ServiceID: "123", - ServiceVersion: 2, - Name: "log", - NewName: fastly.String("new1"), - BucketName: fastly.String("new2"), - AccessKey: fastly.String("new3"), - SecretKey: fastly.String("new4"), - IAMRole: fastly.String(""), - Domain: fastly.String("new5"), - Path: fastly.String("new6"), - Period: fastly.Uint(3601), - GzipLevel: fastly.Uint(0), - Format: fastly.String("new7"), - FormatVersion: fastly.Uint(3), - MessageType: fastly.String("new8"), - ResponseCondition: fastly.String("new9"), - TimestampFormat: fastly.String("new10"), - Placement: fastly.String("new11"), - Redundancy: fastly.S3RedundancyReduced, - ServerSideEncryption: fastly.S3ServerSideEncryptionKMS, - ServerSideEncryptionKMSKeyID: fastly.String("new12"), - PublicKey: fastly.String("new13"), - CompressionCodec: fastly.String("new14"), - }, - }, - { - name: "error missing serviceID", - cmd: updateCommandMissingServiceID(), - want: nil, - wantError: errors.ErrNoServiceID.Error(), - }, - } { - t.Run(testcase.name, func(t *testing.T) { - testcase.cmd.Base.Globals.Client = testcase.api - - have, err := testcase.cmd.createInput() - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertEqual(t, testcase.want, have) - }) - } -} - -func createCommandRequired() *CreateCommand { - return &CreateCommand{ - manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, - EndpointName: "log", - Version: 2, - BucketName: "bucket", - AccessKey: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "access"}, - SecretKey: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "secret"}, - } -} - -func createCommandRequiredIAMRole() *CreateCommand { - return &CreateCommand{ - manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, - EndpointName: "log", - Version: 2, - BucketName: "bucket", - IAMRole: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "arn:aws:iam::123456789012:role/S3Access"}, - } -} - -func createCommandAll() *CreateCommand { - return &CreateCommand{ - manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, - EndpointName: "logs", - Version: 2, - BucketName: "bucket", - AccessKey: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "access"}, - SecretKey: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "secret"}, - Domain: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "domain"}, - Path: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "path"}, - Period: common.OptionalUint{Optional: common.Optional{WasSet: true}, Value: 3600}, - Format: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: `%h %l %u %t "%r" %>s %b`}, - FormatVersion: common.OptionalUint{Optional: common.Optional{WasSet: true}, Value: 2}, - MessageType: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "classic"}, - ResponseCondition: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "Prevent default logging"}, - TimestampFormat: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "%Y-%m-%dT%H:%M:%S.000"}, - Placement: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "none"}, - PublicKey: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: pgpPublicKey()}, - Redundancy: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: string(fastly.S3RedundancyStandard)}, - ServerSideEncryption: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: string(fastly.S3ServerSideEncryptionAES)}, - ServerSideEncryptionKMSKeyID: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "kmskey"}, - CompressionCodec: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "zstd"}, - } -} - -func createCommandMissingServiceID() *CreateCommand { - res := createCommandAll() - res.manifest = manifest.Data{} - return res -} - -func updateCommandNoUpdates() *UpdateCommand { - return &UpdateCommand{ - Base: common.Base{Globals: &config.Data{Client: nil}}, - manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, - EndpointName: "log", - Version: 2, - } -} - -func updateCommandAll() *UpdateCommand { - return &UpdateCommand{ - Base: common.Base{Globals: &config.Data{Client: nil}}, - manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, - EndpointName: "log", - Version: 2, - NewName: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new1"}, - BucketName: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new2"}, - AccessKey: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new3"}, - SecretKey: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new4"}, - IAMRole: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: ""}, - Domain: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new5"}, - Path: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new6"}, - Period: common.OptionalUint{Optional: common.Optional{WasSet: true}, Value: 3601}, - GzipLevel: common.OptionalUint{Optional: common.Optional{WasSet: true}, Value: 0}, - Format: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new7"}, - FormatVersion: common.OptionalUint{Optional: common.Optional{WasSet: true}, Value: 3}, - MessageType: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new8"}, - ResponseCondition: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new9"}, - TimestampFormat: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new10"}, - Placement: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new11"}, - Redundancy: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: string(fastly.S3RedundancyReduced)}, - ServerSideEncryption: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: string(fastly.S3ServerSideEncryptionKMS)}, - ServerSideEncryptionKMSKeyID: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new12"}, - PublicKey: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new13"}, - CompressionCodec: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new14"}, - } -} - -func updateCommandMissingServiceID() *UpdateCommand { - res := updateCommandAll() - res.manifest = manifest.Data{} - return res -} - -func getS3OK(i *fastly.GetS3Input) (*fastly.S3, error) { - return &fastly.S3{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "logs", - BucketName: "bucket", - Domain: "domain", - AccessKey: "access", - SecretKey: "secret", - Path: "path", - Period: 3600, - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - ResponseCondition: "Prevent default logging", - MessageType: "classic", - TimestampFormat: "%Y-%m-%dT%H:%M:%S.000", - Placement: "none", - PublicKey: pgpPublicKey(), - Redundancy: fastly.S3RedundancyStandard, - ServerSideEncryptionKMSKeyID: "kmskey", - ServerSideEncryption: fastly.S3ServerSideEncryptionAES, - CompressionCodec: "zstd", - }, nil -} - -// pgpPublicKey returns a PEM encoded PGP public key suitable for testing. -func pgpPublicKey() string { - return strings.TrimSpace(`-----BEGIN PGP PUBLIC KEY BLOCK----- -mQENBFyUD8sBCACyFnB39AuuTygseek+eA4fo0cgwva6/FSjnWq7riouQee8GgQ/ -ibXTRyv4iVlwI12GswvMTIy7zNvs1R54i0qvsLr+IZ4GVGJqs6ZJnvQcqe3xPoR4 -8AnBfw90o32r/LuHf6QCJXi+AEu35koNlNAvLJ2B+KACaNB7N0EeWmqpV/1V2k9p -lDYk+th7LcCuaFNGqKS/PrMnnMqR6VDLCjHhNx4KR79b0Twm/2qp6an3hyNRu8Gn -dwxpf1/BUu3JWf+LqkN4Y3mbOmSUL3MaJNvyQguUzTfS0P0uGuBDHrJCVkMZCzDB -89ag55jCPHyGeHBTd02gHMWzsg3WMBWvCsrzABEBAAG0JXRlcnJhZm9ybSAodGVz -dCkgPHRlc3RAdGVycmFmb3JtLmNvbT6JAU4EEwEIADgWIQSHYyc6Kj9l6HzQsau6 -vFFc9jxV/wUCXJQPywIbAwULCQgHAgYVCgkICwIEFgIDAQIeAQIXgAAKCRC6vFFc -9jxV/815CAClb32OxV7wG01yF97TzlyTl8TnvjMtoG29Mw4nSyg+mjM3b8N7iXm9 -OLX59fbDAWtBSldSZE22RXd3CvlFOG/EnKBXSjBtEqfyxYSnyOPkMPBYWGL/ApkX -SvPYJ4LKdvipYToKFh3y9kk2gk1DcDBDyaaHvR+3rv1u3aoy7/s2EltAfDS3ZQIq -7/cWTLJml/lleeB/Y6rPj8xqeCYhE5ahw9gsV/Mdqatl24V9Tks30iijx0Hhw+Gx -kATUikMGr2GDVqoIRga5kXI7CzYff4rkc0Twn47fMHHHe/KY9M2yVnMHUXmAZwbG -M1cMI/NH1DjevCKdGBLcRJlhuLPKF/anuQENBFyUD8sBCADIpd7r7GuPd6n/Ikxe -u6h7umV6IIPoAm88xCYpTbSZiaK30Svh6Ywra9jfE2KlU9o6Y/art8ip0VJ3m07L -4RSfSpnzqgSwdjSq5hNour2Fo/BzYhK7yaz2AzVSbe33R0+RYhb4b/6N+bKbjwGF -ftCsqVFMH+PyvYkLbvxyQrHlA9woAZaNThI1ztO5rGSnGUR8xt84eup28WIFKg0K -UEGUcTzz+8QGAwAra+0ewPXo/AkO+8BvZjDidP417u6gpBHOJ9qYIcO9FxHeqFyu -YrjlrxowEgXn5wO8xuNz6Vu1vhHGDHGDsRbZF8pv1d5O+0F1G7ttZ2GRRgVBZPwi -kiyRABEBAAGJATYEGAEIACAWIQSHYyc6Kj9l6HzQsau6vFFc9jxV/wUCXJQPywIb -DAAKCRC6vFFc9jxV/9YOCACe8qmOSnKQpQfW+PqYOqo3dt7JyweTs3FkD6NT8Zml -dYy/vkstbTjPpX6aTvUZjkb46BVi7AOneVHpD5GBqvRsZ9iVgDYHaehmLCdKiG5L -3Tp90NN+QY5WDbsGmsyk6+6ZMYejb4qYfweQeduOj27aavCJdLkCYMoRKfcFYI8c -FaNmEfKKy/r1PO20NXEG6t9t05K/frHy6ZG8bCNYdpagfFVot47r9JaQqWlTNtIR -5+zkkSq/eG9BEtRij3a6cTdQbktdBzx2KBeI0PYc1vlZR0LpuFKZqY9vlE6vTGLR -wMfrTEOvx0NxUM3rpaCgEmuWbB1G1Hu371oyr4srrr+N -=28dr ------END PGP PUBLIC KEY BLOCK----- -`) -} diff --git a/pkg/logging/s3/update.go b/pkg/logging/s3/update.go deleted file mode 100644 index d630e3c37..000000000 --- a/pkg/logging/s3/update.go +++ /dev/null @@ -1,204 +0,0 @@ -package s3 - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// UpdateCommand calls the Fastly API to update an Amazon S3 logging endpoint. -type UpdateCommand struct { - common.Base - manifest manifest.Data - - // required - EndpointName string // Can't shadow common.Base method Name(). - Version int - - // optional - NewName common.OptionalString - Address common.OptionalString - BucketName common.OptionalString - AccessKey common.OptionalString - SecretKey common.OptionalString - IAMRole common.OptionalString - Domain common.OptionalString - Path common.OptionalString - Period common.OptionalUint - GzipLevel common.OptionalUint - Format common.OptionalString - FormatVersion common.OptionalUint - MessageType common.OptionalString - ResponseCondition common.OptionalString - TimestampFormat common.OptionalString - Placement common.OptionalString - PublicKey common.OptionalString - Redundancy common.OptionalString - ServerSideEncryption common.OptionalString - ServerSideEncryptionKMSKeyID common.OptionalString - CompressionCodec common.OptionalString -} - -// NewUpdateCommand returns a usable command registered under the parent. -func NewUpdateCommand(parent common.Registerer, globals *config.Data) *UpdateCommand { - var c UpdateCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - - c.CmdClause = parent.Command("update", "Update a S3 logging endpoint on a Fastly service version") - - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Version) - c.CmdClause.Flag("name", "The name of the S3 logging object").Short('n').Required().StringVar(&c.EndpointName) - - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("new-name", "New name of the S3 logging object").Action(c.NewName.Set).StringVar(&c.NewName.Value) - c.CmdClause.Flag("bucket", "Your S3 bucket name").Action(c.BucketName.Set).StringVar(&c.BucketName.Value) - c.CmdClause.Flag("access-key", "Your S3 account access key").Action(c.AccessKey.Set).StringVar(&c.AccessKey.Value) - c.CmdClause.Flag("secret-key", "Your S3 account secret key").Action(c.SecretKey.Set).StringVar(&c.SecretKey.Value) - c.CmdClause.Flag("iam-role", "The IAM role ARN for logging").Action(c.IAMRole.Set).StringVar(&c.IAMRole.Value) - c.CmdClause.Flag("domain", "The domain of the S3 endpoint").Action(c.Domain.Set).StringVar(&c.Domain.Value) - c.CmdClause.Flag("path", "The path to upload logs to").Action(c.Path.Set).StringVar(&c.Path.Value) - c.CmdClause.Flag("period", "How frequently log files are finalized so they can be available for reading (in seconds, default 3600)").Action(c.Period.Set).UintVar(&c.Period.Value) - c.CmdClause.Flag("gzip-level", "What level of GZIP encoding to have when dumping logs (default 0, no compression)").Action(c.GzipLevel.Set).UintVar(&c.GzipLevel.Value) - c.CmdClause.Flag("format", "Apache style log formatting").Action(c.Format.Set).StringVar(&c.Format.Value) - c.CmdClause.Flag("format-version", "The version of the custom logging format used for the configured endpoint. Can be either 2 (default) or 1").Action(c.FormatVersion.Set).UintVar(&c.FormatVersion.Value) - c.CmdClause.Flag("message-type", "How the message should be formatted. One of: classic (default), loggly, logplex or blank").Action(c.MessageType.Set).StringVar(&c.MessageType.Value) - c.CmdClause.Flag("response-condition", "The name of an existing condition in the configured endpoint, or leave blank to always execute").Action(c.ResponseCondition.Set).StringVar(&c.ResponseCondition.Value) - c.CmdClause.Flag("timestamp-format", `strftime specified timestamp formatting (default "%Y-%m-%dT%H:%M:%S.000")`).Action(c.TimestampFormat.Set).StringVar(&c.TimestampFormat.Value) - c.CmdClause.Flag("redundancy", "The S3 redundancy level. Can be either standard or reduced_redundancy").Action(c.Redundancy.Set).EnumVar(&c.Redundancy.Value, string(fastly.S3RedundancyStandard), string(fastly.S3RedundancyReduced)) - c.CmdClause.Flag("placement", "Where in the generated VCL the logging call should be placed, overriding any format_version default. Can be none or waf_debug").Action(c.Placement.Set).StringVar(&c.Placement.Value) - c.CmdClause.Flag("public-key", "A PGP public key that Fastly will use to encrypt your log files before writing them to disk").Action(c.PublicKey.Set).StringVar(&c.PublicKey.Value) - c.CmdClause.Flag("server-side-encryption", "Set to enable S3 Server Side Encryption. Can be either AES256 or aws:kms").Action(c.ServerSideEncryption.Set).EnumVar(&c.ServerSideEncryption.Value, string(fastly.S3ServerSideEncryptionAES), string(fastly.S3ServerSideEncryptionKMS)) - c.CmdClause.Flag("server-side-encryption-kms-key-id", "Server-side KMS Key ID. Must be set if server-side-encryption is set to aws:kms").Action(c.ServerSideEncryptionKMSKeyID.Set).StringVar(&c.ServerSideEncryptionKMSKeyID.Value) - c.CmdClause.Flag("compression-codec", `The codec used for compression of your logs. Valid values are zstd, snappy, and gzip. If the specified codec is "gzip", gzip_level will default to 3. To specify a different level, leave compression_codec blank and explicitly set the level using gzip_level. Specifying both compression_codec and gzip_level in the same API request will result in an error.`).Action(c.CompressionCodec.Set).StringVar(&c.CompressionCodec.Value) - - return &c -} - -// createInput transforms values parsed from CLI flags into an object to be used by the API client library. -func (c *UpdateCommand) createInput() (*fastly.UpdateS3Input, error) { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return nil, errors.ErrNoServiceID - } - - input := fastly.UpdateS3Input{ - ServiceID: serviceID, - ServiceVersion: c.Version, - Name: c.EndpointName, - } - - if c.NewName.WasSet { - input.NewName = fastly.String(c.NewName.Value) - } - - if c.BucketName.WasSet { - input.BucketName = fastly.String(c.BucketName.Value) - } - - if c.AccessKey.WasSet { - input.AccessKey = fastly.String(c.AccessKey.Value) - } - - if c.SecretKey.WasSet { - input.SecretKey = fastly.String(c.SecretKey.Value) - } - - if c.IAMRole.WasSet { - input.IAMRole = fastly.String(c.IAMRole.Value) - } - - if c.Domain.WasSet { - input.Domain = fastly.String(c.Domain.Value) - } - - if c.Path.WasSet { - input.Path = fastly.String(c.Path.Value) - } - - if c.Period.WasSet { - input.Period = fastly.Uint(c.Period.Value) - } - - if c.GzipLevel.WasSet { - input.GzipLevel = fastly.Uint(c.GzipLevel.Value) - } - - if c.Format.WasSet { - input.Format = fastly.String(c.Format.Value) - } - - if c.FormatVersion.WasSet { - input.FormatVersion = fastly.Uint(c.FormatVersion.Value) - } - - if c.MessageType.WasSet { - input.MessageType = fastly.String(c.MessageType.Value) - } - - if c.ResponseCondition.WasSet { - input.ResponseCondition = fastly.String(c.ResponseCondition.Value) - } - - if c.TimestampFormat.WasSet { - input.TimestampFormat = fastly.String(c.TimestampFormat.Value) - } - - if c.Placement.WasSet { - input.Placement = fastly.String(c.Placement.Value) - } - - if c.PublicKey.WasSet { - input.PublicKey = fastly.String(c.PublicKey.Value) - } - - if c.ServerSideEncryptionKMSKeyID.WasSet { - input.ServerSideEncryptionKMSKeyID = fastly.String(c.ServerSideEncryptionKMSKeyID.Value) - } - - if c.CompressionCodec.WasSet { - input.CompressionCodec = fastly.String(c.CompressionCodec.Value) - } - - if c.Redundancy.WasSet { - switch c.Redundancy.Value { - case string(fastly.S3RedundancyStandard): - input.Redundancy = fastly.S3RedundancyStandard - case string(fastly.S3RedundancyReduced): - input.Redundancy = fastly.S3RedundancyReduced - } - } - - if c.ServerSideEncryption.WasSet { - switch c.ServerSideEncryption.Value { - case string(fastly.S3ServerSideEncryptionAES): - input.ServerSideEncryption = fastly.S3ServerSideEncryptionAES - case string(fastly.S3ServerSideEncryptionKMS): - input.ServerSideEncryption = fastly.S3ServerSideEncryptionKMS - } - } - - return &input, nil -} - -// Exec invokes the application logic for the command. -func (c *UpdateCommand) Exec(in io.Reader, out io.Writer) error { - input, err := c.createInput() - if err != nil { - return err - } - - s3, err := c.Globals.Client.UpdateS3(input) - if err != nil { - return err - } - - text.Success(out, "Updated S3 logging endpoint %s (service %s version %d)", s3.Name, s3.ServiceID, s3.ServiceVersion) - return nil -} diff --git a/pkg/logging/scalyr/create.go b/pkg/logging/scalyr/create.go deleted file mode 100644 index e2e6cdc3f..000000000 --- a/pkg/logging/scalyr/create.go +++ /dev/null @@ -1,106 +0,0 @@ -package scalyr - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// CreateCommand calls the Fastly API to create a Scalyr logging endpoint. -type CreateCommand struct { - common.Base - manifest manifest.Data - - // required - EndpointName string // Can't shadow common.Base method Name(). - Token string - Version int - - // optional - Region common.OptionalString - Format common.OptionalString - FormatVersion common.OptionalUint - ResponseCondition common.OptionalString - Placement common.OptionalString -} - -// NewCreateCommand returns a usable command registered under the parent. -func NewCreateCommand(parent common.Registerer, globals *config.Data) *CreateCommand { - var c CreateCommand - - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("create", "Create a Scalyr logging endpoint on a Fastly service version").Alias("add") - - c.CmdClause.Flag("name", "The name of the Scalyr logging object. Used as a primary key for API access").Short('n').Required().StringVar(&c.EndpointName) - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Version) - c.CmdClause.Flag("auth-token", "The token to use for authentication (https://www.scalyr.com/keys)").Required().StringVar(&c.Token) - - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("region", "The region that log data will be sent to. One of US or EU. Defaults to US if undefined").Action(c.Region.Set).StringVar(&c.Region.Value) - c.CmdClause.Flag("format", "Apache style log formatting").Action(c.Format.Set).StringVar(&c.Format.Value) - c.CmdClause.Flag("format-version", "The version of the custom logging format used for the configured endpoint. Can be either 2 (default) or 1").Action(c.FormatVersion.Set).UintVar(&c.FormatVersion.Value) - c.CmdClause.Flag("response-condition", "The name of an existing condition in the configured endpoint, or leave blank to always execute").Action(c.ResponseCondition.Set).StringVar(&c.ResponseCondition.Value) - c.CmdClause.Flag("placement", "Where in the generated VCL the logging call should be placed, overriding any format_version default. Can be none or waf_debug").Action(c.Placement.Set).StringVar(&c.Placement.Value) - - return &c -} - -// createInput transforms values parsed from CLI flags into an object to be used by the API client library. -func (c *CreateCommand) createInput() (*fastly.CreateScalyrInput, error) { - var input fastly.CreateScalyrInput - - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return nil, errors.ErrNoServiceID - } - - input.ServiceID = serviceID - input.ServiceVersion = c.Version - input.Name = c.EndpointName - input.Token = c.Token - - if c.Region.WasSet { - input.Region = c.Region.Value - } - - if c.Format.WasSet { - input.Format = c.Format.Value - } - - if c.FormatVersion.WasSet { - input.FormatVersion = c.FormatVersion.Value - } - - if c.ResponseCondition.WasSet { - input.ResponseCondition = c.ResponseCondition.Value - } - - if c.Placement.WasSet { - input.Placement = c.Placement.Value - } - - return &input, nil -} - -// Exec invokes the application logic for the command. -func (c *CreateCommand) Exec(in io.Reader, out io.Writer) error { - input, err := c.createInput() - if err != nil { - return err - } - - d, err := c.Globals.Client.CreateScalyr(input) - if err != nil { - return err - } - - text.Success(out, "Created Scalyr logging endpoint %s (service %s version %d)", d.Name, d.ServiceID, d.ServiceVersion) - return nil -} diff --git a/pkg/logging/scalyr/delete.go b/pkg/logging/scalyr/delete.go deleted file mode 100644 index 92d74a419..000000000 --- a/pkg/logging/scalyr/delete.go +++ /dev/null @@ -1,51 +0,0 @@ -package scalyr - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// DeleteCommand calls the Fastly API to delete a Scalyr logging endpoint. -type DeleteCommand struct { - common.Base - manifest manifest.Data - Input fastly.DeleteScalyrInput -} - -// NewDeleteCommand returns a usable command registered under the parent. -func NewDeleteCommand(parent common.Registerer, globals *config.Data) *DeleteCommand { - var c DeleteCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("delete", "Delete a Scalyr logging endpoint on a Fastly service version").Alias("remove") - - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Input.ServiceVersion) - c.CmdClause.Flag("name", "The name of the Scalyr logging object").Short('n').Required().StringVar(&c.Input.Name) - - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - - return &c -} - -// Exec invokes the application logic for the command. -func (c *DeleteCommand) Exec(in io.Reader, out io.Writer) error { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - c.Input.ServiceID = serviceID - - if err := c.Globals.Client.DeleteScalyr(&c.Input); err != nil { - return err - } - - text.Success(out, "Deleted Scalyr logging endpoint %s (service %s version %d)", c.Input.Name, c.Input.ServiceID, c.Input.ServiceVersion) - return nil -} diff --git a/pkg/logging/scalyr/describe.go b/pkg/logging/scalyr/describe.go deleted file mode 100644 index 2278c35da..000000000 --- a/pkg/logging/scalyr/describe.go +++ /dev/null @@ -1,58 +0,0 @@ -package scalyr - -import ( - "fmt" - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/go-fastly/v3/fastly" -) - -// DescribeCommand calls the Fastly API to describe a Scalyr logging endpoint. -type DescribeCommand struct { - common.Base - manifest manifest.Data - Input fastly.GetScalyrInput -} - -// NewDescribeCommand returns a usable command registered under the parent. -func NewDescribeCommand(parent common.Registerer, globals *config.Data) *DescribeCommand { - var c DescribeCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("describe", "Show detailed information about a Scalyr logging endpoint on a Fastly service version").Alias("get") - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Input.ServiceVersion) - c.CmdClause.Flag("name", "The name of the Scalyr logging object").Short('n').Required().StringVar(&c.Input.Name) - return &c -} - -// Exec invokes the application logic for the command. -func (c *DescribeCommand) Exec(in io.Reader, out io.Writer) error { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - c.Input.ServiceID = serviceID - - scalyr, err := c.Globals.Client.GetScalyr(&c.Input) - if err != nil { - return err - } - - fmt.Fprintf(out, "Service ID: %s\n", scalyr.ServiceID) - fmt.Fprintf(out, "Version: %d\n", scalyr.ServiceVersion) - fmt.Fprintf(out, "Name: %s\n", scalyr.Name) - fmt.Fprintf(out, "Token: %s\n", scalyr.Token) - fmt.Fprintf(out, "Region: %s\n", scalyr.Region) - fmt.Fprintf(out, "Format: %s\n", scalyr.Format) - fmt.Fprintf(out, "Format version: %d\n", scalyr.FormatVersion) - fmt.Fprintf(out, "Response condition: %s\n", scalyr.ResponseCondition) - fmt.Fprintf(out, "Placement: %s\n", scalyr.Placement) - - return nil -} diff --git a/pkg/logging/scalyr/list.go b/pkg/logging/scalyr/list.go deleted file mode 100644 index 15c381b92..000000000 --- a/pkg/logging/scalyr/list.go +++ /dev/null @@ -1,74 +0,0 @@ -package scalyr - -import ( - "fmt" - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// ListCommand calls the Fastly API to list Scalyr logging endpoints. -type ListCommand struct { - common.Base - manifest manifest.Data - Input fastly.ListScalyrsInput -} - -// NewListCommand returns a usable command registered under the parent. -func NewListCommand(parent common.Registerer, globals *config.Data) *ListCommand { - var c ListCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("list", "List Scalyr endpoints on a Fastly service version") - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Input.ServiceVersion) - return &c -} - -// Exec invokes the application logic for the command. -func (c *ListCommand) Exec(in io.Reader, out io.Writer) error { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - c.Input.ServiceID = serviceID - - scalyrs, err := c.Globals.Client.ListScalyrs(&c.Input) - if err != nil { - return err - } - - if !c.Globals.Verbose() { - tw := text.NewTable(out) - tw.AddHeader("SERVICE", "VERSION", "NAME") - for _, scalyr := range scalyrs { - tw.AddLine(scalyr.ServiceID, scalyr.ServiceVersion, scalyr.Name) - } - tw.Print() - return nil - } - - fmt.Fprintf(out, "Service ID: %s\n", c.Input.ServiceID) - fmt.Fprintf(out, "Version: %d\n", c.Input.ServiceVersion) - for i, scalyr := range scalyrs { - fmt.Fprintf(out, "\tScalyr %d/%d\n", i+1, len(scalyrs)) - fmt.Fprintf(out, "\t\tService ID: %s\n", scalyr.ServiceID) - fmt.Fprintf(out, "\t\tVersion: %d\n", scalyr.ServiceVersion) - fmt.Fprintf(out, "\t\tName: %s\n", scalyr.Name) - fmt.Fprintf(out, "\t\tToken: %s\n", scalyr.Token) - fmt.Fprintf(out, "\t\tRegion: %s\n", scalyr.Region) - fmt.Fprintf(out, "\t\tFormat: %s\n", scalyr.Format) - fmt.Fprintf(out, "\t\tFormat version: %d\n", scalyr.FormatVersion) - fmt.Fprintf(out, "\t\tResponse condition: %s\n", scalyr.ResponseCondition) - fmt.Fprintf(out, "\t\tPlacement: %s\n", scalyr.Placement) - } - fmt.Fprintln(out) - - return nil -} diff --git a/pkg/logging/scalyr/root.go b/pkg/logging/scalyr/root.go deleted file mode 100644 index fb601fc5b..000000000 --- a/pkg/logging/scalyr/root.go +++ /dev/null @@ -1,28 +0,0 @@ -package scalyr - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/config" -) - -// RootCommand is the parent command for all subcommands in this package. -// It should be installed under the primary root command. -type RootCommand struct { - common.Base - // no flags -} - -// NewRootCommand returns a new command registered in the parent. -func NewRootCommand(parent common.Registerer, globals *config.Data) *RootCommand { - var c RootCommand - c.Globals = globals - c.CmdClause = parent.Command("scalyr", "Manipulate Fastly service version Scalyr logging endpoints") - return &c -} - -// Exec implements the command interface. -func (c *RootCommand) Exec(in io.Reader, out io.Writer) error { - panic("unreachable") -} diff --git a/pkg/logging/scalyr/scalyr_integration_test.go b/pkg/logging/scalyr/scalyr_integration_test.go deleted file mode 100644 index 9f9e462e7..000000000 --- a/pkg/logging/scalyr/scalyr_integration_test.go +++ /dev/null @@ -1,408 +0,0 @@ -package scalyr_test - -import ( - "bytes" - "errors" - "io" - "net/http" - "strings" - "testing" - - "github.com/fastly/cli/pkg/app" - "github.com/fastly/cli/pkg/config" - fsterrs "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/mock" - "github.com/fastly/cli/pkg/testutil" - "github.com/fastly/cli/pkg/update" - "github.com/fastly/go-fastly/v3/fastly" -) - -func TestScalyrCreate(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "scalyr", "create", "--service-id", "123", "--version", "1", "--auth-token", "abc"}, - wantError: "error parsing arguments: required flag --name not provided", - }, - { - args: []string{"logging", "scalyr", "create", "--service-id", "123", "--version", "1", "--name", "log"}, - wantError: "error parsing arguments: required flag --auth-token not provided", - }, - { - args: []string{"logging", "scalyr", "create", "--name", "log", "--service-id", "", "--version", "1", "--auth-token", "abc"}, - wantError: fsterrs.ErrNoServiceID.Error(), - }, - { - args: []string{"logging", "scalyr", "create", "--service-id", "123", "--version", "1", "--name", "log", "--auth-token", "abc"}, - api: mock.API{CreateScalyrFn: createScalyrOK}, - wantOutput: "Created Scalyr logging endpoint log (service 123 version 1)", - }, - { - args: []string{"logging", "scalyr", "create", "--service-id", "123", "--version", "1", "--name", "log", "--auth-token", "abc"}, - api: mock.API{CreateScalyrFn: createScalyrError}, - wantError: errTest.Error(), - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertStringContains(t, out.String(), testcase.wantOutput) - }) - } -} - -func TestScalyrList(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "scalyr", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListScalyrsFn: listScalyrsOK}, - wantOutput: listScalyrsShortOutput, - }, - { - args: []string{"logging", "scalyr", "list", "--service-id", "123", "--version", "1", "--verbose"}, - api: mock.API{ListScalyrsFn: listScalyrsOK}, - wantOutput: listScalyrsVerboseOutput, - }, - { - args: []string{"logging", "scalyr", "list", "--service-id", "123", "--version", "1", "-v"}, - api: mock.API{ListScalyrsFn: listScalyrsOK}, - wantOutput: listScalyrsVerboseOutput, - }, - { - args: []string{"logging", "scalyr", "--verbose", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListScalyrsFn: listScalyrsOK}, - wantOutput: listScalyrsVerboseOutput, - }, - { - args: []string{"logging", "-v", "scalyr", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListScalyrsFn: listScalyrsOK}, - wantOutput: listScalyrsVerboseOutput, - }, - { - args: []string{"logging", "scalyr", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListScalyrsFn: listScalyrsError}, - wantError: errTest.Error(), - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertString(t, testcase.wantOutput, out.String()) - }) - } -} - -func TestScalyrDescribe(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "scalyr", "describe", "--service-id", "123", "--version", "1"}, - wantError: "error parsing arguments: required flag --name not provided", - }, - { - args: []string{"logging", "scalyr", "describe", "--service-id", "123", "--version", "1", "--name", "logs"}, - api: mock.API{GetScalyrFn: getScalyrError}, - wantError: errTest.Error(), - }, - { - args: []string{"logging", "scalyr", "describe", "--service-id", "123", "--version", "1", "--name", "logs"}, - api: mock.API{GetScalyrFn: getScalyrOK}, - wantOutput: describeScalyrOutput, - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertString(t, testcase.wantOutput, out.String()) - }) - } -} - -func TestScalyrUpdate(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "scalyr", "update", "--service-id", "123", "--version", "1", "--new-name", "log"}, - wantError: "error parsing arguments: required flag --name not provided", - }, - { - args: []string{"logging", "scalyr", "update", "--service-id", "123", "--version", "1", "--name", "logs", "--new-name", "log"}, - api: mock.API{UpdateScalyrFn: updateScalyrError}, - wantError: errTest.Error(), - }, - { - args: []string{"logging", "scalyr", "update", "--service-id", "123", "--version", "1", "--name", "logs", "--new-name", "log"}, - api: mock.API{UpdateScalyrFn: updateScalyrOK}, - wantOutput: "Updated Scalyr logging endpoint log (service 123 version 1)", - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertStringContains(t, out.String(), testcase.wantOutput) - }) - } -} - -func TestScalyrDelete(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "scalyr", "delete", "--service-id", "123", "--version", "1"}, - wantError: "error parsing arguments: required flag --name not provided", - }, - { - args: []string{"logging", "scalyr", "delete", "--service-id", "123", "--version", "1", "--name", "logs"}, - api: mock.API{DeleteScalyrFn: deleteScalyrError}, - wantError: errTest.Error(), - }, - { - args: []string{"logging", "scalyr", "delete", "--service-id", "123", "--version", "1", "--name", "logs"}, - api: mock.API{DeleteScalyrFn: deleteScalyrOK}, - wantOutput: "Deleted Scalyr logging endpoint logs (service 123 version 1)", - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertStringContains(t, out.String(), testcase.wantOutput) - }) - } -} - -var errTest = errors.New("fixture error") - -func createScalyrOK(i *fastly.CreateScalyrInput) (*fastly.Scalyr, error) { - s := fastly.Scalyr{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - } - - // Avoids null pointer dereference for test cases with missing required params. - // If omitted, tests are guaranteed to panic. - if i.Name != "" { - s.Name = i.Name - } - - if i.Token != "" { - s.Token = i.Token - } - - if i.Format != "" { - s.Format = i.Format - } - - if i.FormatVersion != 0 { - s.FormatVersion = i.FormatVersion - } - - if i.ResponseCondition != "" { - s.ResponseCondition = i.ResponseCondition - } - - if i.Placement != "" { - s.Placement = i.Placement - } - - return &s, nil -} - -func createScalyrError(i *fastly.CreateScalyrInput) (*fastly.Scalyr, error) { - return nil, errTest -} - -func listScalyrsOK(i *fastly.ListScalyrsInput) ([]*fastly.Scalyr, error) { - return []*fastly.Scalyr{ - { - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "logs", - Token: "abc", - Region: "US", - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - ResponseCondition: "Prevent default logging", - Placement: "none", - }, - { - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "analytics", - Token: "abc", - Region: "US", - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - ResponseCondition: "Prevent default logging", - Placement: "none", - }, - }, nil -} - -func listScalyrsError(i *fastly.ListScalyrsInput) ([]*fastly.Scalyr, error) { - return nil, errTest -} - -var listScalyrsShortOutput = strings.TrimSpace(` -SERVICE VERSION NAME -123 1 logs -123 1 analytics -`) + "\n" - -var listScalyrsVerboseOutput = strings.TrimSpace(` -Fastly API token not provided -Fastly API endpoint: https://api.fastly.com -Service ID: 123 -Version: 1 - Scalyr 1/2 - Service ID: 123 - Version: 1 - Name: logs - Token: abc - Region: US - Format: %h %l %u %t "%r" %>s %b - Format version: 2 - Response condition: Prevent default logging - Placement: none - Scalyr 2/2 - Service ID: 123 - Version: 1 - Name: analytics - Token: abc - Region: US - Format: %h %l %u %t "%r" %>s %b - Format version: 2 - Response condition: Prevent default logging - Placement: none -`) + "\n\n" - -func getScalyrOK(i *fastly.GetScalyrInput) (*fastly.Scalyr, error) { - return &fastly.Scalyr{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "logs", - Token: "abc", - Region: "US", - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - ResponseCondition: "Prevent default logging", - Placement: "none", - }, nil -} - -func getScalyrError(i *fastly.GetScalyrInput) (*fastly.Scalyr, error) { - return nil, errTest -} - -var describeScalyrOutput = strings.TrimSpace(` -Service ID: 123 -Version: 1 -Name: logs -Token: abc -Region: US -Format: %h %l %u %t "%r" %>s %b -Format version: 2 -Response condition: Prevent default logging -Placement: none -`) + "\n" - -func updateScalyrOK(i *fastly.UpdateScalyrInput) (*fastly.Scalyr, error) { - return &fastly.Scalyr{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "log", - Token: "abc", - Region: "EU", - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - ResponseCondition: "Prevent default logging", - Placement: "none", - }, nil -} - -func updateScalyrError(i *fastly.UpdateScalyrInput) (*fastly.Scalyr, error) { - return nil, errTest -} - -func deleteScalyrOK(i *fastly.DeleteScalyrInput) error { - return nil -} - -func deleteScalyrError(i *fastly.DeleteScalyrInput) error { - return errTest -} diff --git a/pkg/logging/scalyr/scalyr_test.go b/pkg/logging/scalyr/scalyr_test.go deleted file mode 100644 index e5b57ded9..000000000 --- a/pkg/logging/scalyr/scalyr_test.go +++ /dev/null @@ -1,186 +0,0 @@ -package scalyr - -import ( - "testing" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/mock" - "github.com/fastly/cli/pkg/testutil" - "github.com/fastly/go-fastly/v3/fastly" -) - -func TestCreateScalyrInput(t *testing.T) { - for _, testcase := range []struct { - name string - cmd *CreateCommand - want *fastly.CreateScalyrInput - wantError string - }{ - { - name: "required values set flag serviceID", - cmd: createCommandRequired(), - want: &fastly.CreateScalyrInput{ - ServiceID: "123", - ServiceVersion: 2, - Name: "log", - Token: "tkn", - }, - }, - { - name: "all values set flag serviceID", - cmd: createCommandAll(), - want: &fastly.CreateScalyrInput{ - ServiceID: "123", - ServiceVersion: 2, - Name: "log", - Token: "tkn", - Region: "US", - FormatVersion: 2, - Format: `%h %l %u %t "%r" %>s %b`, - ResponseCondition: "Prevent default logging", - Placement: "none", - }, - }, - { - name: "error missing serviceID", - cmd: createCommandMissingServiceID(), - want: nil, - wantError: errors.ErrNoServiceID.Error(), - }, - } { - t.Run(testcase.name, func(t *testing.T) { - have, err := testcase.cmd.createInput() - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertEqual(t, testcase.want, have) - }) - } -} - -func TestUpdateScalyrInput(t *testing.T) { - for _, testcase := range []struct { - name string - cmd *UpdateCommand - api mock.API - want *fastly.UpdateScalyrInput - wantError string - }{ - { - name: "no updates", - cmd: updateCommandNoUpdates(), - api: mock.API{GetScalyrFn: getScalyrOK}, - want: &fastly.UpdateScalyrInput{ - ServiceID: "123", - ServiceVersion: 2, - Name: "log", - }, - }, - { - name: "all values set flag serviceID", - cmd: updateCommandAll(), - api: mock.API{GetScalyrFn: getScalyrOK}, - want: &fastly.UpdateScalyrInput{ - ServiceID: "123", - ServiceVersion: 2, - Name: "log", - NewName: fastly.String("new1"), - Token: fastly.String("new2"), - FormatVersion: fastly.Uint(3), - Format: fastly.String("new3"), - ResponseCondition: fastly.String("new4"), - Placement: fastly.String("new5"), - Region: fastly.String("new6"), - }, - }, - { - name: "error missing serviceID", - cmd: updateCommandMissingServiceID(), - want: nil, - wantError: errors.ErrNoServiceID.Error(), - }, - } { - t.Run(testcase.name, func(t *testing.T) { - testcase.cmd.Base.Globals.Client = testcase.api - - have, err := testcase.cmd.createInput() - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertEqual(t, testcase.want, have) - }) - } -} - -func createCommandRequired() *CreateCommand { - return &CreateCommand{ - manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, - EndpointName: "log", - Version: 2, - Token: "tkn", - } -} - -func createCommandAll() *CreateCommand { - return &CreateCommand{ - manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, - EndpointName: "log", - Version: 2, - Token: "tkn", - Region: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "US"}, - Format: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: `%h %l %u %t "%r" %>s %b`}, - FormatVersion: common.OptionalUint{Optional: common.Optional{WasSet: true}, Value: 2}, - ResponseCondition: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "Prevent default logging"}, - Placement: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "none"}, - } -} - -func createCommandMissingServiceID() *CreateCommand { - res := createCommandAll() - res.manifest = manifest.Data{} - return res -} - -func updateCommandNoUpdates() *UpdateCommand { - return &UpdateCommand{ - Base: common.Base{Globals: &config.Data{Client: nil}}, - manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, - EndpointName: "log", - Version: 2, - } -} - -func updateCommandAll() *UpdateCommand { - return &UpdateCommand{ - Base: common.Base{Globals: &config.Data{Client: nil}}, - manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, - EndpointName: "log", - Version: 2, - NewName: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new1"}, - Token: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new2"}, - Format: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new3"}, - FormatVersion: common.OptionalUint{Optional: common.Optional{WasSet: true}, Value: 3}, - ResponseCondition: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new4"}, - Placement: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new5"}, - Region: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new6"}, - } -} - -func updateCommandMissingServiceID() *UpdateCommand { - res := updateCommandAll() - res.manifest = manifest.Data{} - return res -} - -func getScalyrOK(i *fastly.GetScalyrInput) (*fastly.Scalyr, error) { - return &fastly.Scalyr{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "logs", - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - Token: "tkn", - Region: "US", - ResponseCondition: "Prevent default logging", - Placement: "none", - }, nil -} diff --git a/pkg/logging/scalyr/update.go b/pkg/logging/scalyr/update.go deleted file mode 100644 index dab1616f7..000000000 --- a/pkg/logging/scalyr/update.go +++ /dev/null @@ -1,115 +0,0 @@ -package scalyr - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// UpdateCommand calls the Fastly API to update Scalyr logging endpoints. -type UpdateCommand struct { - common.Base - manifest manifest.Data - - // required - EndpointName string // Can't shadow common.Base method Name(). - Version int - - // optional - NewName common.OptionalString - Format common.OptionalString - FormatVersion common.OptionalUint - Token common.OptionalString - Region common.OptionalString - ResponseCondition common.OptionalString - Placement common.OptionalString -} - -// NewUpdateCommand returns a usable command registered under the parent. -func NewUpdateCommand(parent common.Registerer, globals *config.Data) *UpdateCommand { - var c UpdateCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - - c.CmdClause = parent.Command("update", "Update a Scalyr logging endpoint on a Fastly service version") - - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Version) - c.CmdClause.Flag("name", "The name of the Scalyr logging object").Short('n').Required().StringVar(&c.EndpointName) - - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("new-name", "New name of the Scalyr logging object").Action(c.NewName.Set).StringVar(&c.NewName.Value) - c.CmdClause.Flag("format", "Apache style log formatting").Action(c.Format.Set).StringVar(&c.Format.Value) - c.CmdClause.Flag("format-version", "The version of the custom logging format used for the configured endpoint. Can be either 2 (default) or 1").Action(c.FormatVersion.Set).UintVar(&c.FormatVersion.Value) - c.CmdClause.Flag("auth-token", "The token to use for authentication (https://www.scalyr.com/keys)").Action(c.Token.Set).StringVar(&c.Token.Value) - c.CmdClause.Flag("region", "The region that log data will be sent to. One of US or EU. Defaults to US if undefined").Action(c.Region.Set).StringVar(&c.Region.Value) - c.CmdClause.Flag("response-condition", "The name of an existing condition in the configured endpoint, or leave blank to always execute").Action(c.ResponseCondition.Set).StringVar(&c.ResponseCondition.Value) - c.CmdClause.Flag("placement", "Where in the generated VCL the logging call should be placed, overriding any format_version default. Can be none or waf_debug").Action(c.Placement.Set).StringVar(&c.Placement.Value) - - return &c -} - -// createInput transforms values parsed from CLI flags into an object to be used by the API client library. -func (c *UpdateCommand) createInput() (*fastly.UpdateScalyrInput, error) { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return nil, errors.ErrNoServiceID - } - - input := fastly.UpdateScalyrInput{ - ServiceID: serviceID, - ServiceVersion: c.Version, - Name: c.EndpointName, - } - - if c.NewName.WasSet { - input.NewName = fastly.String(c.NewName.Value) - } - - if c.Format.WasSet { - input.Format = fastly.String(c.Format.Value) - } - - if c.FormatVersion.WasSet { - input.FormatVersion = fastly.Uint(c.FormatVersion.Value) - } - - if c.Token.WasSet { - input.Token = fastly.String(c.Token.Value) - } - - if c.Region.WasSet { - input.Region = fastly.String(c.Region.Value) - } - - if c.ResponseCondition.WasSet { - input.ResponseCondition = fastly.String(c.ResponseCondition.Value) - } - - if c.Placement.WasSet { - input.Placement = fastly.String(c.Placement.Value) - } - - return &input, nil -} - -// Exec invokes the application logic for the command. -func (c *UpdateCommand) Exec(in io.Reader, out io.Writer) error { - input, err := c.createInput() - if err != nil { - return err - } - - scalyr, err := c.Globals.Client.UpdateScalyr(input) - if err != nil { - return err - } - - text.Success(out, "Updated Scalyr logging endpoint %s (service %s version %d)", scalyr.Name, scalyr.ServiceID, scalyr.ServiceVersion) - return nil -} diff --git a/pkg/logging/sftp/create.go b/pkg/logging/sftp/create.go deleted file mode 100644 index e83e1ea00..000000000 --- a/pkg/logging/sftp/create.go +++ /dev/null @@ -1,174 +0,0 @@ -package sftp - -import ( - "fmt" - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// CreateCommand calls the Fastly API to create an SFTP logging endpoint. -type CreateCommand struct { - common.Base - manifest manifest.Data - - // required - EndpointName string // Can't shadow common.Base method Name(). - Version int - Address string - User string - SSHKnownHosts string - - // optional - Port common.OptionalUint - Password common.OptionalString - PublicKey common.OptionalString - SecretKey common.OptionalString - Path common.OptionalString - Period common.OptionalUint - Format common.OptionalString - FormatVersion common.OptionalUint - GzipLevel common.OptionalUint - MessageType common.OptionalString - ResponseCondition common.OptionalString - TimestampFormat common.OptionalString - Placement common.OptionalString - CompressionCodec common.OptionalString -} - -// NewCreateCommand returns a usable command registered under the parent. -func NewCreateCommand(parent common.Registerer, globals *config.Data) *CreateCommand { - var c CreateCommand - - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("create", "Create an SFTP logging endpoint on a Fastly service version").Alias("add") - - c.CmdClause.Flag("name", "The name of the SFTP logging object. Used as a primary key for API access").Short('n').Required().StringVar(&c.EndpointName) - - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Version) - c.CmdClause.Flag("address", "The hostname or IPv4 addres").Required().StringVar(&c.Address) - c.CmdClause.Flag("user", "The username for the server").Required().StringVar(&c.User) - c.CmdClause.Flag("ssh-known-hosts", "A list of host keys for all hosts we can connect to over SFTP").Required().StringVar(&c.SSHKnownHosts) - - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("port", "The port number").Action(c.Port.Set).UintVar(&c.Port.Value) - c.CmdClause.Flag("password", "The password for the server. If both password and secret_key are passed, secret_key will be used in preference").Action(c.Password.Set).StringVar(&c.Password.Value) - c.CmdClause.Flag("public-key", "A PGP public key that Fastly will use to encrypt your log files before writing them to disk").Action(c.PublicKey.Set).StringVar(&c.PublicKey.Value) - c.CmdClause.Flag("secret-key", "The SSH private key for the server. If both password and secret_key are passed, secret_key will be used in preference").Action(c.SecretKey.Set).StringVar(&c.SecretKey.Value) - c.CmdClause.Flag("path", "The path to upload logs to. The directory must exist on the SFTP server before logs can be saved to it").Action(c.Path.Set).StringVar(&c.Path.Value) - c.CmdClause.Flag("period", "How frequently log files are finalized so they can be available for reading (in seconds, default 3600)").Action(c.Period.Set).UintVar(&c.Period.Value) - c.CmdClause.Flag("format", "Apache style log formatting").Action(c.Format.Set).StringVar(&c.Format.Value) - c.CmdClause.Flag("format-version", "The version of the custom logging format used for the configured endpoint. Can be either 2 (default) or 1").Action(c.FormatVersion.Set).UintVar(&c.FormatVersion.Value) - c.CmdClause.Flag("gzip-level", "What level of GZIP encoding to have when dumping logs (default 0, no compression)").Action(c.GzipLevel.Set).UintVar(&c.GzipLevel.Value) - c.CmdClause.Flag("message-type", "How the message should be formatted. One of: classic (default), loggly, logplex or blank").Action(c.MessageType.Set).StringVar(&c.MessageType.Value) - c.CmdClause.Flag("response-condition", "The name of an existing condition in the configured endpoint, or leave blank to always execute").Action(c.ResponseCondition.Set).StringVar(&c.ResponseCondition.Value) - c.CmdClause.Flag("timestamp-format", `strftime specified timestamp formatting (default "%Y-%m-%dT%H:%M:%S.000")`).Action(c.TimestampFormat.Set).StringVar(&c.TimestampFormat.Value) - c.CmdClause.Flag("placement", "Where in the generated VCL the logging call should be placed, overriding any format_version default. Can be none or waf_debug").Action(c.Placement.Set).StringVar(&c.Placement.Value) - c.CmdClause.Flag("compression-codec", `The codec used for compression of your logs. Valid values are zstd, snappy, and gzip. If the specified codec is "gzip", gzip_level will default to 3. To specify a different level, leave compression_codec blank and explicitly set the level using gzip_level. Specifying both compression_codec and gzip_level in the same API request will result in an error.`).Action(c.CompressionCodec.Set).StringVar(&c.CompressionCodec.Value) - - return &c -} - -// createInput transforms values parsed from CLI flags into an object to be used by the API client library. -func (c *CreateCommand) createInput() (*fastly.CreateSFTPInput, error) { - var input fastly.CreateSFTPInput - - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return nil, errors.ErrNoServiceID - } - - input.ServiceID = serviceID - input.ServiceVersion = c.Version - input.Name = c.EndpointName - input.Address = c.Address - input.User = c.User - input.SSHKnownHosts = c.SSHKnownHosts - - // The following blocks enforces the mutual exclusivity of the - // CompressionCodec and GzipLevel flags. - if c.CompressionCodec.WasSet && c.GzipLevel.WasSet { - return nil, fmt.Errorf("error parsing arguments: the --compression-codec flag is mutually exclusive with the --gzip-level flag") - } - - if c.Port.WasSet { - input.Port = c.Port.Value - } - - if c.Password.WasSet { - input.Password = c.Password.Value - } - - if c.PublicKey.WasSet { - input.PublicKey = c.PublicKey.Value - } - - if c.SecretKey.WasSet { - input.SecretKey = c.SecretKey.Value - } - - if c.Path.WasSet { - input.Path = c.Path.Value - } - - if c.Period.WasSet { - input.Period = c.Period.Value - } - - if c.Format.WasSet { - input.Format = c.Format.Value - } - - if c.FormatVersion.WasSet { - input.FormatVersion = c.FormatVersion.Value - } - - if c.GzipLevel.WasSet { - input.GzipLevel = c.GzipLevel.Value - } - - if c.MessageType.WasSet { - input.MessageType = c.MessageType.Value - } - - if c.ResponseCondition.WasSet { - input.ResponseCondition = c.ResponseCondition.Value - } - - if c.TimestampFormat.WasSet { - input.TimestampFormat = c.TimestampFormat.Value - } - - if c.Placement.WasSet { - input.Placement = c.Placement.Value - } - - if c.CompressionCodec.WasSet { - input.CompressionCodec = c.CompressionCodec.Value - } - - return &input, nil -} - -// Exec invokes the application logic for the command. -func (c *CreateCommand) Exec(in io.Reader, out io.Writer) error { - input, err := c.createInput() - if err != nil { - return err - } - - d, err := c.Globals.Client.CreateSFTP(input) - if err != nil { - return err - } - - text.Success(out, "Created SFTP logging endpoint %s (service %s version %d)", d.Name, d.ServiceID, d.ServiceVersion) - return nil -} diff --git a/pkg/logging/sftp/delete.go b/pkg/logging/sftp/delete.go deleted file mode 100644 index 9591c7613..000000000 --- a/pkg/logging/sftp/delete.go +++ /dev/null @@ -1,51 +0,0 @@ -package sftp - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// DeleteCommand calls the Fastly API to delete an SFTP logging endpoint. -type DeleteCommand struct { - common.Base - manifest manifest.Data - Input fastly.DeleteSFTPInput -} - -// NewDeleteCommand returns a usable command registered under the parent. -func NewDeleteCommand(parent common.Registerer, globals *config.Data) *DeleteCommand { - var c DeleteCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("delete", "Delete an SFTP logging endpoint on a Fastly service version").Alias("remove") - - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Input.ServiceVersion) - c.CmdClause.Flag("name", "The name of the SFTP logging object").Short('n').Required().StringVar(&c.Input.Name) - - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - - return &c -} - -// Exec invokes the application logic for the command. -func (c *DeleteCommand) Exec(in io.Reader, out io.Writer) error { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - c.Input.ServiceID = serviceID - - if err := c.Globals.Client.DeleteSFTP(&c.Input); err != nil { - return err - } - - text.Success(out, "Deleted SFTP logging endpoint %s (service %s version %d)", c.Input.Name, c.Input.ServiceID, c.Input.ServiceVersion) - return nil -} diff --git a/pkg/logging/sftp/describe.go b/pkg/logging/sftp/describe.go deleted file mode 100644 index d54c4cbbb..000000000 --- a/pkg/logging/sftp/describe.go +++ /dev/null @@ -1,69 +0,0 @@ -package sftp - -import ( - "fmt" - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/go-fastly/v3/fastly" -) - -// DescribeCommand calls the Fastly API to describe an SFTP logging endpoint. -type DescribeCommand struct { - common.Base - manifest manifest.Data - Input fastly.GetSFTPInput -} - -// NewDescribeCommand returns a usable command registered under the parent. -func NewDescribeCommand(parent common.Registerer, globals *config.Data) *DescribeCommand { - var c DescribeCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("describe", "Show detailed information about an SFTP logging endpoint on a Fastly service version").Alias("get") - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Input.ServiceVersion) - c.CmdClause.Flag("name", "The name of the SFTP logging object").Short('n').Required().StringVar(&c.Input.Name) - return &c -} - -// Exec invokes the application logic for the command. -func (c *DescribeCommand) Exec(in io.Reader, out io.Writer) error { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - c.Input.ServiceID = serviceID - - sftp, err := c.Globals.Client.GetSFTP(&c.Input) - if err != nil { - return err - } - - fmt.Fprintf(out, "Service ID: %s\n", sftp.ServiceID) - fmt.Fprintf(out, "Version: %d\n", sftp.ServiceVersion) - fmt.Fprintf(out, "Name: %s\n", sftp.Name) - fmt.Fprintf(out, "Address: %s\n", sftp.Address) - fmt.Fprintf(out, "Port: %d\n", sftp.Port) - fmt.Fprintf(out, "User: %s\n", sftp.User) - fmt.Fprintf(out, "Password: %s\n", sftp.Password) - fmt.Fprintf(out, "Public key: %s\n", sftp.PublicKey) - fmt.Fprintf(out, "Secret key: %s\n", sftp.SecretKey) - fmt.Fprintf(out, "SSH known hosts: %s\n", sftp.SSHKnownHosts) - fmt.Fprintf(out, "Path: %s\n", sftp.Path) - fmt.Fprintf(out, "Period: %d\n", sftp.Period) - fmt.Fprintf(out, "GZip level: %d\n", sftp.GzipLevel) - fmt.Fprintf(out, "Format: %s\n", sftp.Format) - fmt.Fprintf(out, "Format version: %d\n", sftp.FormatVersion) - fmt.Fprintf(out, "Message type: %s\n", sftp.MessageType) - fmt.Fprintf(out, "Response condition: %s\n", sftp.ResponseCondition) - fmt.Fprintf(out, "Timestamp format: %s\n", sftp.TimestampFormat) - fmt.Fprintf(out, "Placement: %s\n", sftp.Placement) - fmt.Fprintf(out, "Compression codec: %s\n", sftp.CompressionCodec) - - return nil -} diff --git a/pkg/logging/sftp/list.go b/pkg/logging/sftp/list.go deleted file mode 100644 index 5a8aa9412..000000000 --- a/pkg/logging/sftp/list.go +++ /dev/null @@ -1,85 +0,0 @@ -package sftp - -import ( - "fmt" - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// ListCommand calls the Fastly API to list SFTP logging endpoints. -type ListCommand struct { - common.Base - manifest manifest.Data - Input fastly.ListSFTPsInput -} - -// NewListCommand returns a usable command registered under the parent. -func NewListCommand(parent common.Registerer, globals *config.Data) *ListCommand { - var c ListCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("list", "List SFTP endpoints on a Fastly service version") - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Input.ServiceVersion) - return &c -} - -// Exec invokes the application logic for the command. -func (c *ListCommand) Exec(in io.Reader, out io.Writer) error { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - c.Input.ServiceID = serviceID - - sftps, err := c.Globals.Client.ListSFTPs(&c.Input) - if err != nil { - return err - } - - if !c.Globals.Verbose() { - tw := text.NewTable(out) - tw.AddHeader("SERVICE", "VERSION", "NAME") - for _, sftp := range sftps { - tw.AddLine(sftp.ServiceID, sftp.ServiceVersion, sftp.Name) - } - tw.Print() - return nil - } - - fmt.Fprintf(out, "Service ID: %s\n", c.Input.ServiceID) - fmt.Fprintf(out, "Version: %d\n", c.Input.ServiceVersion) - for i, sftp := range sftps { - fmt.Fprintf(out, "\tSFTP %d/%d\n", i+1, len(sftps)) - fmt.Fprintf(out, "\t\tService ID: %s\n", sftp.ServiceID) - fmt.Fprintf(out, "\t\tVersion: %d\n", sftp.ServiceVersion) - fmt.Fprintf(out, "\t\tName: %s\n", sftp.Name) - fmt.Fprintf(out, "\t\tAddress: %s\n", sftp.Address) - fmt.Fprintf(out, "\t\tPort: %d\n", sftp.Port) - fmt.Fprintf(out, "\t\tUser: %s\n", sftp.User) - fmt.Fprintf(out, "\t\tPassword: %s\n", sftp.Password) - fmt.Fprintf(out, "\t\tPublic key: %s\n", sftp.PublicKey) - fmt.Fprintf(out, "\t\tSecret key: %s\n", sftp.SecretKey) - fmt.Fprintf(out, "\t\tSSH known hosts: %s\n", sftp.SSHKnownHosts) - fmt.Fprintf(out, "\t\tPath: %s\n", sftp.Path) - fmt.Fprintf(out, "\t\tPeriod: %d\n", sftp.Period) - fmt.Fprintf(out, "\t\tGZip level: %d\n", sftp.GzipLevel) - fmt.Fprintf(out, "\t\tFormat: %s\n", sftp.Format) - fmt.Fprintf(out, "\t\tFormat version: %d\n", sftp.FormatVersion) - fmt.Fprintf(out, "\t\tMessage type: %s\n", sftp.MessageType) - fmt.Fprintf(out, "\t\tResponse condition: %s\n", sftp.ResponseCondition) - fmt.Fprintf(out, "\t\tTimestamp format: %s\n", sftp.TimestampFormat) - fmt.Fprintf(out, "\t\tPlacement: %s\n", sftp.Placement) - fmt.Fprintf(out, "\t\tCompression codec: %s\n", sftp.CompressionCodec) - } - fmt.Fprintln(out) - - return nil -} diff --git a/pkg/logging/sftp/root.go b/pkg/logging/sftp/root.go deleted file mode 100644 index aa06c01c8..000000000 --- a/pkg/logging/sftp/root.go +++ /dev/null @@ -1,28 +0,0 @@ -package sftp - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/config" -) - -// RootCommand is the parent command for all subcommands in this package. -// It should be installed under the primary root command. -type RootCommand struct { - common.Base - // no flags -} - -// NewRootCommand returns a new command registered in the parent. -func NewRootCommand(parent common.Registerer, globals *config.Data) *RootCommand { - var c RootCommand - c.Globals = globals - c.CmdClause = parent.Command("sftp", "Manipulate Fastly service version SFTP logging endpoints") - return &c -} - -// Exec implements the command interface. -func (c *RootCommand) Exec(in io.Reader, out io.Writer) error { - panic("unreachable") -} diff --git a/pkg/logging/sftp/sftp_integration_test.go b/pkg/logging/sftp/sftp_integration_test.go deleted file mode 100644 index 9696ac362..000000000 --- a/pkg/logging/sftp/sftp_integration_test.go +++ /dev/null @@ -1,525 +0,0 @@ -package sftp_test - -import ( - "bytes" - "errors" - "io" - "net/http" - "strings" - "testing" - - "github.com/fastly/cli/pkg/app" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/mock" - "github.com/fastly/cli/pkg/testutil" - "github.com/fastly/cli/pkg/update" - "github.com/fastly/go-fastly/v3/fastly" -) - -func TestSFTPCreate(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "sftp", "create", "--service-id", "123", "--version", "1", "--name", "log", "--user", "user", "--ssh-known-hosts", knownHosts(), "--port", "80"}, - wantError: "error parsing arguments: required flag --address not provided", - }, - { - args: []string{"logging", "sftp", "create", "--service-id", "123", "--version", "1", "--name", "log", "--address", "example.com", "--ssh-known-hosts", knownHosts(), "--port", "80"}, - wantError: "error parsing arguments: required flag --user not provided", - }, - { - args: []string{"logging", "sftp", "create", "--service-id", "123", "--version", "1", "--name", "log", "--address", "example.com", "--user", "user", "--port", "80"}, - wantError: "error parsing arguments: required flag --ssh-known-hosts not provided", - }, - { - args: []string{"logging", "sftp", "create", "--service-id", "123", "--version", "1", "--name", "log", "--address", "example.com", "--user", "user", "--ssh-known-hosts", knownHosts(), "--port", "80"}, - api: mock.API{CreateSFTPFn: createSFTPOK}, - wantOutput: "Created SFTP logging endpoint log (service 123 version 1)", - }, - { - args: []string{"logging", "sftp", "create", "--service-id", "123", "--version", "1", "--name", "log", "--address", "example.com", "--user", "user", "--ssh-known-hosts", knownHosts(), "--port", "80"}, - api: mock.API{CreateSFTPFn: createSFTPError}, - wantError: errTest.Error(), - }, - { - args: []string{"logging", "sftp", "create", "--service-id", "123", "--version", "1", "--name", "log", "--address", "example.com", "--user", "anonymous", "--ssh-known-hosts", knownHosts(), "--port", "80", "--compression-codec", "zstd", "--gzip-level", "9"}, - wantError: "error parsing arguments: the --compression-codec flag is mutually exclusive with the --gzip-level flag", - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertStringContains(t, out.String(), testcase.wantOutput) - }) - } -} - -func TestSFTPList(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "sftp", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListSFTPsFn: listSFTPsOK}, - wantOutput: listSFTPsShortOutput, - }, - { - args: []string{"logging", "sftp", "list", "--service-id", "123", "--version", "1", "--verbose"}, - api: mock.API{ListSFTPsFn: listSFTPsOK}, - wantOutput: listSFTPsVerboseOutput, - }, - { - args: []string{"logging", "sftp", "list", "--service-id", "123", "--version", "1", "-v"}, - api: mock.API{ListSFTPsFn: listSFTPsOK}, - wantOutput: listSFTPsVerboseOutput, - }, - { - args: []string{"logging", "sftp", "--verbose", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListSFTPsFn: listSFTPsOK}, - wantOutput: listSFTPsVerboseOutput, - }, - { - args: []string{"logging", "-v", "sftp", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListSFTPsFn: listSFTPsOK}, - wantOutput: listSFTPsVerboseOutput, - }, - { - args: []string{"logging", "sftp", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListSFTPsFn: listSFTPsError}, - wantError: errTest.Error(), - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertString(t, testcase.wantOutput, out.String()) - }) - } -} - -func TestSFTPDescribe(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "sftp", "describe", "--service-id", "123", "--version", "1"}, - wantError: "error parsing arguments: required flag --name not provided", - }, - { - args: []string{"logging", "sftp", "describe", "--service-id", "123", "--version", "1", "--name", "logs"}, - api: mock.API{GetSFTPFn: getSFTPError}, - wantError: errTest.Error(), - }, - { - args: []string{"logging", "sftp", "describe", "--service-id", "123", "--version", "1", "--name", "logs"}, - api: mock.API{GetSFTPFn: getSFTPOK}, - wantOutput: describeSFTPOutput, - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertString(t, testcase.wantOutput, out.String()) - }) - } -} - -func TestSFTPUpdate(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "sftp", "update", "--service-id", "123", "--version", "1", "--new-name", "log"}, - wantError: "error parsing arguments: required flag --name not provided", - }, - { - args: []string{"logging", "sftp", "update", "--service-id", "123", "--version", "1", "--name", "logs", "--new-name", "log"}, - api: mock.API{UpdateSFTPFn: updateSFTPError}, - wantError: errTest.Error(), - }, - { - args: []string{"logging", "sftp", "update", "--service-id", "123", "--version", "1", "--name", "logs", "--new-name", "log"}, - api: mock.API{UpdateSFTPFn: updateSFTPOK}, - wantOutput: "Updated SFTP logging endpoint log (service 123 version 1)", - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertStringContains(t, out.String(), testcase.wantOutput) - }) - } -} - -func TestSFTPDelete(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "sftp", "delete", "--service-id", "123", "--version", "1"}, - wantError: "error parsing arguments: required flag --name not provided", - }, - { - args: []string{"logging", "sftp", "delete", "--service-id", "123", "--version", "1", "--name", "logs"}, - api: mock.API{DeleteSFTPFn: deleteSFTPError}, - wantError: errTest.Error(), - }, - { - args: []string{"logging", "sftp", "delete", "--service-id", "123", "--version", "1", "--name", "logs"}, - api: mock.API{DeleteSFTPFn: deleteSFTPOK}, - wantOutput: "Deleted SFTP logging endpoint logs (service 123 version 1)", - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertStringContains(t, out.String(), testcase.wantOutput) - }) - } -} - -var errTest = errors.New("fixture error") - -func createSFTPOK(i *fastly.CreateSFTPInput) (*fastly.SFTP, error) { - s := fastly.SFTP{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - CompressionCodec: "zstd", - } - - if i.Name != "" { - s.Name = i.Name - } - - return &s, nil -} - -func createSFTPError(i *fastly.CreateSFTPInput) (*fastly.SFTP, error) { - return nil, errTest -} - -func listSFTPsOK(i *fastly.ListSFTPsInput) ([]*fastly.SFTP, error) { - return []*fastly.SFTP{ - { - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "logs", - Address: "127.0.0.1", - Port: 514, - User: "user", - Password: "password", - PublicKey: pgpPublicKey(), - SecretKey: sshPrivateKey(), - SSHKnownHosts: knownHosts(), - Path: "/logs", - Period: 3600, - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - MessageType: "classic", - ResponseCondition: "Prevent default logging", - TimestampFormat: "%Y-%m-%dT%H:%M:%S.000", - Placement: "none", - CompressionCodec: "zstd", - }, - { - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "analytics", - Address: "example.com", - Port: 123, - User: "user", - Password: "password", - PublicKey: pgpPublicKey(), - SecretKey: sshPrivateKey(), - SSHKnownHosts: knownHosts(), - Path: "/analytics", - Period: 3600, - Format: `%h %l %u %t "%r" %>s %b`, - MessageType: "classic", - FormatVersion: 2, - ResponseCondition: "Prevent default logging", - TimestampFormat: "%Y-%m-%dT%H:%M:%S.000", - Placement: "none", - CompressionCodec: "zstd", - }, - }, nil -} - -func listSFTPsError(i *fastly.ListSFTPsInput) ([]*fastly.SFTP, error) { - return nil, errTest -} - -var listSFTPsShortOutput = strings.TrimSpace(` -SERVICE VERSION NAME -123 1 logs -123 1 analytics -`) + "\n" - -var listSFTPsVerboseOutput = strings.TrimSpace(` -Fastly API token not provided -Fastly API endpoint: https://api.fastly.com -Service ID: 123 -Version: 1 - SFTP 1/2 - Service ID: 123 - Version: 1 - Name: logs - Address: 127.0.0.1 - Port: 514 - User: user - Password: password - Public key: `+pgpPublicKey()+` - Secret key: `+sshPrivateKey()+` - SSH known hosts: `+knownHosts()+` - Path: /logs - Period: 3600 - GZip level: 0 - Format: %h %l %u %t "%r" %>s %b - Format version: 2 - Message type: classic - Response condition: Prevent default logging - Timestamp format: %Y-%m-%dT%H:%M:%S.000 - Placement: none - Compression codec: zstd - SFTP 2/2 - Service ID: 123 - Version: 1 - Name: analytics - Address: example.com - Port: 123 - User: user - Password: password - Public key: `+pgpPublicKey()+` - Secret key: `+sshPrivateKey()+` - SSH known hosts: `+knownHosts()+` - Path: /analytics - Period: 3600 - GZip level: 0 - Format: %h %l %u %t "%r" %>s %b - Format version: 2 - Message type: classic - Response condition: Prevent default logging - Timestamp format: %Y-%m-%dT%H:%M:%S.000 - Placement: none - Compression codec: zstd -`) + "\n\n" - -func getSFTPOK(i *fastly.GetSFTPInput) (*fastly.SFTP, error) { - return &fastly.SFTP{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "logs", - Address: "example.com", - Port: 514, - User: "user", - Password: "password", - PublicKey: pgpPublicKey(), - SecretKey: sshPrivateKey(), - SSHKnownHosts: knownHosts(), - Path: "/logs", - Period: 3600, - GzipLevel: 2, - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - MessageType: "classic", - ResponseCondition: "Prevent default logging", - TimestampFormat: "%Y-%m-%dT%H:%M:%S.000", - Placement: "none", - CompressionCodec: "zstd", - }, nil -} - -func getSFTPError(i *fastly.GetSFTPInput) (*fastly.SFTP, error) { - return nil, errTest -} - -var describeSFTPOutput = strings.TrimSpace(` -Service ID: 123 -Version: 1 -Name: logs -Address: example.com -Port: 514 -User: user -Password: password -Public key: `+pgpPublicKey()+` -Secret key: `+sshPrivateKey()+` -SSH known hosts: `+knownHosts()+` -Path: /logs -Period: 3600 -GZip level: 2 -Format: %h %l %u %t "%r" %>s %b -Format version: 2 -Message type: classic -Response condition: Prevent default logging -Timestamp format: %Y-%m-%dT%H:%M:%S.000 -Placement: none -Compression codec: zstd -`) + "\n" - -func updateSFTPOK(i *fastly.UpdateSFTPInput) (*fastly.SFTP, error) { - return &fastly.SFTP{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "log", - Address: "example.com", - Port: 514, - User: "user", - Password: "password", - PublicKey: pgpPublicKey(), - SecretKey: sshPrivateKey(), - SSHKnownHosts: knownHosts(), - Path: "/logs", - Period: 3600, - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - MessageType: "classic", - ResponseCondition: "Prevent default logging", - TimestampFormat: "%Y-%m-%dT%H:%M:%S.000", - Placement: "none", - CompressionCodec: "zstd", - }, nil -} - -func updateSFTPError(i *fastly.UpdateSFTPInput) (*fastly.SFTP, error) { - return nil, errTest -} - -func deleteSFTPOK(i *fastly.DeleteSFTPInput) error { - return nil -} - -func deleteSFTPError(i *fastly.DeleteSFTPInput) error { - return errTest -} - -// knownHosts returns sample known hosts suitable for testing -func knownHosts() string { - return strings.TrimSpace(` -example.com -127.0.0.1 -`) -} - -// pgpPublicKey returns a PEM encoded PGP public key suitable for testing. -func pgpPublicKey() string { - return strings.TrimSpace(`-----BEGIN PGP PUBLIC KEY BLOCK----- -mQENBFyUD8sBCACyFnB39AuuTygseek+eA4fo0cgwva6/FSjnWq7riouQee8GgQ/ -ibXTRyv4iVlwI12GswvMTIy7zNvs1R54i0qvsLr+IZ4GVGJqs6ZJnvQcqe3xPoR4 -8AnBfw90o32r/LuHf6QCJXi+AEu35koNlNAvLJ2B+KACaNB7N0EeWmqpV/1V2k9p -lDYk+th7LcCuaFNGqKS/PrMnnMqR6VDLCjHhNx4KR79b0Twm/2qp6an3hyNRu8Gn -dwxpf1/BUu3JWf+LqkN4Y3mbOmSUL3MaJNvyQguUzTfS0P0uGuBDHrJCVkMZCzDB -89ag55jCPHyGeHBTd02gHMWzsg3WMBWvCsrzABEBAAG0JXRlcnJhZm9ybSAodGVz -dCkgPHRlc3RAdGVycmFmb3JtLmNvbT6JAU4EEwEIADgWIQSHYyc6Kj9l6HzQsau6 -vFFc9jxV/wUCXJQPywIbAwULCQgHAgYVCgkICwIEFgIDAQIeAQIXgAAKCRC6vFFc -9jxV/815CAClb32OxV7wG01yF97TzlyTl8TnvjMtoG29Mw4nSyg+mjM3b8N7iXm9 -OLX59fbDAWtBSldSZE22RXd3CvlFOG/EnKBXSjBtEqfyxYSnyOPkMPBYWGL/ApkX -SvPYJ4LKdvipYToKFh3y9kk2gk1DcDBDyaaHvR+3rv1u3aoy7/s2EltAfDS3ZQIq -7/cWTLJml/lleeB/Y6rPj8xqeCYhE5ahw9gsV/Mdqatl24V9Tks30iijx0Hhw+Gx -kATUikMGr2GDVqoIRga5kXI7CzYff4rkc0Twn47fMHHHe/KY9M2yVnMHUXmAZwbG -M1cMI/NH1DjevCKdGBLcRJlhuLPKF/anuQENBFyUD8sBCADIpd7r7GuPd6n/Ikxe -u6h7umV6IIPoAm88xCYpTbSZiaK30Svh6Ywra9jfE2KlU9o6Y/art8ip0VJ3m07L -4RSfSpnzqgSwdjSq5hNour2Fo/BzYhK7yaz2AzVSbe33R0+RYhb4b/6N+bKbjwGF -ftCsqVFMH+PyvYkLbvxyQrHlA9woAZaNThI1ztO5rGSnGUR8xt84eup28WIFKg0K -UEGUcTzz+8QGAwAra+0ewPXo/AkO+8BvZjDidP417u6gpBHOJ9qYIcO9FxHeqFyu -YrjlrxowEgXn5wO8xuNz6Vu1vhHGDHGDsRbZF8pv1d5O+0F1G7ttZ2GRRgVBZPwi -kiyRABEBAAGJATYEGAEIACAWIQSHYyc6Kj9l6HzQsau6vFFc9jxV/wUCXJQPywIb -DAAKCRC6vFFc9jxV/9YOCACe8qmOSnKQpQfW+PqYOqo3dt7JyweTs3FkD6NT8Zml -dYy/vkstbTjPpX6aTvUZjkb46BVi7AOneVHpD5GBqvRsZ9iVgDYHaehmLCdKiG5L -3Tp90NN+QY5WDbsGmsyk6+6ZMYejb4qYfweQeduOj27aavCJdLkCYMoRKfcFYI8c -FaNmEfKKy/r1PO20NXEG6t9t05K/frHy6ZG8bCNYdpagfFVot47r9JaQqWlTNtIR -5+zkkSq/eG9BEtRij3a6cTdQbktdBzx2KBeI0PYc1vlZR0LpuFKZqY9vlE6vTGLR -wMfrTEOvx0NxUM3rpaCgEmuWbB1G1Hu371oyr4srrr+N -=28dr ------END PGP PUBLIC KEY BLOCK----- -`) -} - -// sshPrivateKey returns a private key suitable for testing. -func sshPrivateKey() string { - return strings.TrimSpace(`-----BEGIN RSA PRIVATE KEY----- -MIICXAIBAAKBgQDDo+/YbQ1cZVoRhZ/bbQtPxpycDS5Lty+M8e5swCKpmo0/Eym2 -KrVpEVMoU8eGtwVRvGDR2LtmFKvd86QUWkn2V3lYgY66SNj9n4R/YSDT4/GRkg+4 -Egi++ihpZA+SAIODF4+l1bh/FFu0XUpQLXvJ4Tm0++7bm3tEq+XQr9znrwIDAQAB -AoGAfDa374e9te47s2hNyLmBNxN5F7Nes4AJVsm8gZuz5k9UYrm+AAU5zQ3M6IvY -4PWPEQgzyMh8oyF4xaENikaRMhSMfinUmTd979cHbOM6cEKPk28oQcIybsdSzX7G -ZWRh65Ze1DUmBe6R2BUh3Zn4lq9PsqB0TeZeV7Xo/VaIpFECQQDoznQi8HOY8MNM -7ZDdRhFAkS2X5OGqXOjYdLABGNvJhajgoRsTbgDyJG83qn6yYq7wEHYlMddGZ3ln -RLnpsThjAkEA1yGXae8WURFEqjp5dMLBxU07apKvEF4zK1OxZ0VjIOJdIpoRBBuL -IthGBuMrfbF1W5tlmQlj5ik0KhVpBZoHRQJAZP7DdTDZBT1VjHb3RHcUHu2cWOvL -VkvuG5ErlZ5CIv+gDqr1gw1SzbkuoniNdDfJao3Jo0Mm//z9tuYivRXLvwJBALG3 -Wzi0vI/Nnxas5YayGJaf3XSFpj70QnsJUWUJagFRXjTmZyYohsELPpYT9eqIvXUm -o0BQBImvAhu9whtRia0CQCFdDHdNnyyzKH8vC0NsEN65h3Bp2KEPkv8SOV27ZRR2 -xIGqLusk3y+yzbueLZJ117osdB1Owr19fvAHR7vq6Mw= ------END RSA PRIVATE KEY-----`) -} diff --git a/pkg/logging/sftp/sftp_test.go b/pkg/logging/sftp/sftp_test.go deleted file mode 100644 index abd19a859..000000000 --- a/pkg/logging/sftp/sftp_test.go +++ /dev/null @@ -1,304 +0,0 @@ -package sftp - -import ( - "strings" - "testing" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/mock" - "github.com/fastly/cli/pkg/testutil" - "github.com/fastly/go-fastly/v3/fastly" -) - -func TestCreateSFTPInput(t *testing.T) { - for _, testcase := range []struct { - name string - cmd *CreateCommand - want *fastly.CreateSFTPInput - wantError string - }{ - { - name: "required values set flag serviceID", - cmd: createCommandRequired(), - want: &fastly.CreateSFTPInput{ - ServiceID: "123", - ServiceVersion: 2, - Name: "log", - Address: "127.0.0.1", - User: "user", - SSHKnownHosts: knownHosts(), - }, - }, - { - name: "all values set flag serviceID", - cmd: createCommandAll(), - want: &fastly.CreateSFTPInput{ - ServiceID: "123", - ServiceVersion: 2, - Name: "log", - Address: "127.0.0.1", - Port: 80, - User: "user", - Password: "password", - PublicKey: pgpPublicKey(), - SecretKey: sshPrivateKey(), - SSHKnownHosts: knownHosts(), - Path: "/log", - Period: 3600, - FormatVersion: 2, - Format: `%h %l %u %t "%r" %>s %b`, - ResponseCondition: "Prevent default logging", - MessageType: "classic", - TimestampFormat: "%Y-%m-%dT%H:%M:%S.000", - Placement: "none", - CompressionCodec: "zstd", - }, - }, - { - name: "error missing serviceID", - cmd: createCommandMissingServiceID(), - want: nil, - wantError: errors.ErrNoServiceID.Error(), - }, - } { - t.Run(testcase.name, func(t *testing.T) { - have, err := testcase.cmd.createInput() - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertEqual(t, testcase.want, have) - }) - } -} - -func TestUpdateSFTPInput(t *testing.T) { - for _, testcase := range []struct { - name string - cmd *UpdateCommand - api mock.API - want *fastly.UpdateSFTPInput - wantError string - }{ - { - name: "all values set flag serviceID", - cmd: updateCommandAll(), - api: mock.API{GetSFTPFn: getSFTPOK}, - want: &fastly.UpdateSFTPInput{ - ServiceID: "123", - ServiceVersion: 2, - Name: "log", - NewName: fastly.String("new1"), - Address: fastly.String("new2"), - Port: fastly.Uint(81), - User: fastly.String("new3"), - SSHKnownHosts: fastly.String("new4"), - Password: fastly.String("new5"), - PublicKey: fastly.String("new6"), - SecretKey: fastly.String("new7"), - Path: fastly.String("new8"), - Period: fastly.Uint(3601), - FormatVersion: fastly.Uint(3), - GzipLevel: fastly.Uint(0), - Format: fastly.String("new9"), - ResponseCondition: fastly.String("new10"), - TimestampFormat: fastly.String("new11"), - Placement: fastly.String("new12"), - MessageType: fastly.String("new13"), - CompressionCodec: fastly.String("new14"), - }, - }, - { - name: "no updates", - cmd: updateCommandNoUpdates(), - api: mock.API{GetSFTPFn: getSFTPOK}, - want: &fastly.UpdateSFTPInput{ - ServiceID: "123", - ServiceVersion: 2, - Name: "log", - }, - }, - { - name: "error missing serviceID", - cmd: updateCommandMissingServiceID(), - want: nil, - wantError: errors.ErrNoServiceID.Error(), - }, - } { - t.Run(testcase.name, func(t *testing.T) { - testcase.cmd.Base.Globals.Client = testcase.api - - have, err := testcase.cmd.createInput() - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertEqual(t, testcase.want, have) - }) - } -} - -func createCommandRequired() *CreateCommand { - return &CreateCommand{ - manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, - EndpointName: "log", - Version: 2, - Address: "127.0.0.1", - User: "user", - SSHKnownHosts: knownHosts(), - } -} - -func createCommandAll() *CreateCommand { - return &CreateCommand{ - manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, - EndpointName: "log", - Version: 2, - Address: "127.0.0.1", - User: "user", - SSHKnownHosts: knownHosts(), - Port: common.OptionalUint{Optional: common.Optional{WasSet: true}, Value: 80}, - Password: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "password"}, - PublicKey: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: pgpPublicKey()}, - SecretKey: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: sshPrivateKey()}, - Path: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "/log"}, - Period: common.OptionalUint{Optional: common.Optional{WasSet: true}, Value: 3600}, - Format: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: `%h %l %u %t "%r" %>s %b`}, - FormatVersion: common.OptionalUint{Optional: common.Optional{WasSet: true}, Value: 2}, - MessageType: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "classic"}, - ResponseCondition: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "Prevent default logging"}, - TimestampFormat: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "%Y-%m-%dT%H:%M:%S.000"}, - Placement: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "none"}, - CompressionCodec: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "zstd"}, - } -} - -func createCommandMissingServiceID() *CreateCommand { - res := createCommandAll() - res.manifest = manifest.Data{} - return res -} - -func updateCommandNoUpdates() *UpdateCommand { - return &UpdateCommand{ - Base: common.Base{Globals: &config.Data{Client: nil}}, - manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, - EndpointName: "log", - Version: 2, - } -} - -func updateCommandAll() *UpdateCommand { - return &UpdateCommand{ - Base: common.Base{Globals: &config.Data{Client: nil}}, - manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, - EndpointName: "log", - Version: 2, - NewName: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new1"}, - Address: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new2"}, - User: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new3"}, - SSHKnownHosts: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new4"}, - Port: common.OptionalUint{Optional: common.Optional{WasSet: true}, Value: 81}, - Password: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new5"}, - PublicKey: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new6"}, - SecretKey: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new7"}, - Path: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new8"}, - Period: common.OptionalUint{Optional: common.Optional{WasSet: true}, Value: 3601}, - Format: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new9"}, - FormatVersion: common.OptionalUint{Optional: common.Optional{WasSet: true}, Value: 3}, - GzipLevel: common.OptionalUint{Optional: common.Optional{WasSet: true}, Value: 0}, - ResponseCondition: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new10"}, - TimestampFormat: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new11"}, - Placement: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new12"}, - MessageType: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new13"}, - CompressionCodec: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new14"}, - } -} - -func updateCommandMissingServiceID() *UpdateCommand { - res := updateCommandAll() - res.manifest = manifest.Data{} - return res -} - -func getSFTPOK(i *fastly.GetSFTPInput) (*fastly.SFTP, error) { - return &fastly.SFTP{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "logs", - Address: "127.0.0.1", - Port: 80, - User: "user", - Password: "password", - PublicKey: pgpPublicKey(), - SecretKey: sshPrivateKey(), - SSHKnownHosts: knownHosts(), - Path: "/log", - Period: 3600, - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - MessageType: "classic", - ResponseCondition: "Prevent default logging", - TimestampFormat: "%Y-%m-%dT%H:%M:%S.000", - Placement: "none", - CompressionCodec: "zstd", - }, nil -} - -// knownHosts returns sample known hosts suitable for testing -func knownHosts() string { - return strings.TrimSpace(` -example.com -127.0.0.1 -`) -} - -// pgpPublicKey returns a PEM encoded PGP public key suitable for testing. -func pgpPublicKey() string { - return strings.TrimSpace(`-----BEGIN PGP PUBLIC KEY BLOCK----- -mQENBFyUD8sBCACyFnB39AuuTygseek+eA4fo0cgwva6/FSjnWq7riouQee8GgQ/ -ibXTRyv4iVlwI12GswvMTIy7zNvs1R54i0qvsLr+IZ4GVGJqs6ZJnvQcqe3xPoR4 -8AnBfw90o32r/LuHf6QCJXi+AEu35koNlNAvLJ2B+KACaNB7N0EeWmqpV/1V2k9p -lDYk+th7LcCuaFNGqKS/PrMnnMqR6VDLCjHhNx4KR79b0Twm/2qp6an3hyNRu8Gn -dwxpf1/BUu3JWf+LqkN4Y3mbOmSUL3MaJNvyQguUzTfS0P0uGuBDHrJCVkMZCzDB -89ag55jCPHyGeHBTd02gHMWzsg3WMBWvCsrzABEBAAG0JXRlcnJhZm9ybSAodGVz -dCkgPHRlc3RAdGVycmFmb3JtLmNvbT6JAU4EEwEIADgWIQSHYyc6Kj9l6HzQsau6 -vFFc9jxV/wUCXJQPywIbAwULCQgHAgYVCgkICwIEFgIDAQIeAQIXgAAKCRC6vFFc -9jxV/815CAClb32OxV7wG01yF97TzlyTl8TnvjMtoG29Mw4nSyg+mjM3b8N7iXm9 -OLX59fbDAWtBSldSZE22RXd3CvlFOG/EnKBXSjBtEqfyxYSnyOPkMPBYWGL/ApkX -SvPYJ4LKdvipYToKFh3y9kk2gk1DcDBDyaaHvR+3rv1u3aoy7/s2EltAfDS3ZQIq -7/cWTLJml/lleeB/Y6rPj8xqeCYhE5ahw9gsV/Mdqatl24V9Tks30iijx0Hhw+Gx -kATUikMGr2GDVqoIRga5kXI7CzYff4rkc0Twn47fMHHHe/KY9M2yVnMHUXmAZwbG -M1cMI/NH1DjevCKdGBLcRJlhuLPKF/anuQENBFyUD8sBCADIpd7r7GuPd6n/Ikxe -u6h7umV6IIPoAm88xCYpTbSZiaK30Svh6Ywra9jfE2KlU9o6Y/art8ip0VJ3m07L -4RSfSpnzqgSwdjSq5hNour2Fo/BzYhK7yaz2AzVSbe33R0+RYhb4b/6N+bKbjwGF -ftCsqVFMH+PyvYkLbvxyQrHlA9woAZaNThI1ztO5rGSnGUR8xt84eup28WIFKg0K -UEGUcTzz+8QGAwAra+0ewPXo/AkO+8BvZjDidP417u6gpBHOJ9qYIcO9FxHeqFyu -YrjlrxowEgXn5wO8xuNz6Vu1vhHGDHGDsRbZF8pv1d5O+0F1G7ttZ2GRRgVBZPwi -kiyRABEBAAGJATYEGAEIACAWIQSHYyc6Kj9l6HzQsau6vFFc9jxV/wUCXJQPywIb -DAAKCRC6vFFc9jxV/9YOCACe8qmOSnKQpQfW+PqYOqo3dt7JyweTs3FkD6NT8Zml -dYy/vkstbTjPpX6aTvUZjkb46BVi7AOneVHpD5GBqvRsZ9iVgDYHaehmLCdKiG5L -3Tp90NN+QY5WDbsGmsyk6+6ZMYejb4qYfweQeduOj27aavCJdLkCYMoRKfcFYI8c -FaNmEfKKy/r1PO20NXEG6t9t05K/frHy6ZG8bCNYdpagfFVot47r9JaQqWlTNtIR -5+zkkSq/eG9BEtRij3a6cTdQbktdBzx2KBeI0PYc1vlZR0LpuFKZqY9vlE6vTGLR -wMfrTEOvx0NxUM3rpaCgEmuWbB1G1Hu371oyr4srrr+N -=28dr ------END PGP PUBLIC KEY BLOCK----- -`) -} - -// sshPrivateKey returns a private key suitable for testing. -func sshPrivateKey() string { - return strings.TrimSpace(`-----BEGIN RSA PRIVATE KEY----- -MIICXAIBAAKBgQDDo+/YbQ1cZVoRhZ/bbQtPxpycDS5Lty+M8e5swCKpmo0/Eym2 -KrVpEVMoU8eGtwVRvGDR2LtmFKvd86QUWkn2V3lYgY66SNj9n4R/YSDT4/GRkg+4 -Egi++ihpZA+SAIODF4+l1bh/FFu0XUpQLXvJ4Tm0++7bm3tEq+XQr9znrwIDAQAB -AoGAfDa374e9te47s2hNyLmBNxN5F7Nes4AJVsm8gZuz5k9UYrm+AAU5zQ3M6IvY -4PWPEQgzyMh8oyF4xaENikaRMhSMfinUmTd979cHbOM6cEKPk28oQcIybsdSzX7G -ZWRh65Ze1DUmBe6R2BUh3Zn4lq9PsqB0TeZeV7Xo/VaIpFECQQDoznQi8HOY8MNM -7ZDdRhFAkS2X5OGqXOjYdLABGNvJhajgoRsTbgDyJG83qn6yYq7wEHYlMddGZ3ln -RLnpsThjAkEA1yGXae8WURFEqjp5dMLBxU07apKvEF4zK1OxZ0VjIOJdIpoRBBuL -IthGBuMrfbF1W5tlmQlj5ik0KhVpBZoHRQJAZP7DdTDZBT1VjHb3RHcUHu2cWOvL -VkvuG5ErlZ5CIv+gDqr1gw1SzbkuoniNdDfJao3Jo0Mm//z9tuYivRXLvwJBALG3 -Wzi0vI/Nnxas5YayGJaf3XSFpj70QnsJUWUJagFRXjTmZyYohsELPpYT9eqIvXUm -o0BQBImvAhu9whtRia0CQCFdDHdNnyyzKH8vC0NsEN65h3Bp2KEPkv8SOV27ZRR2 -xIGqLusk3y+yzbueLZJ117osdB1Owr19fvAHR7vq6Mw= ------END RSA PRIVATE KEY-----`) -} diff --git a/pkg/logging/sftp/update.go b/pkg/logging/sftp/update.go deleted file mode 100644 index 458e6befd..000000000 --- a/pkg/logging/sftp/update.go +++ /dev/null @@ -1,181 +0,0 @@ -package sftp - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// UpdateCommand calls the Fastly API to update an SFTP logging endpoint. -type UpdateCommand struct { - common.Base - manifest manifest.Data - - // required - EndpointName string - Version int - - // optional - NewName common.OptionalString - Address common.OptionalString - Port common.OptionalUint - PublicKey common.OptionalString - SecretKey common.OptionalString - SSHKnownHosts common.OptionalString - User common.OptionalString - Password common.OptionalString - Path common.OptionalString - Period common.OptionalUint - FormatVersion common.OptionalUint - GzipLevel common.OptionalUint - Format common.OptionalString - MessageType common.OptionalString - ResponseCondition common.OptionalString - TimestampFormat common.OptionalString - Placement common.OptionalString - CompressionCodec common.OptionalString -} - -// NewUpdateCommand returns a usable command registered under the parent. -func NewUpdateCommand(parent common.Registerer, globals *config.Data) *UpdateCommand { - var c UpdateCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - - c.CmdClause = parent.Command("update", "Update an SFTP logging endpoint on a Fastly service version") - - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Version) - c.CmdClause.Flag("name", "The name of the SFTP logging object").Short('n').Required().StringVar(&c.EndpointName) - - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("new-name", "New name of the SFTP logging object").Action(c.NewName.Set).StringVar(&c.NewName.Value) - c.CmdClause.Flag("address", "The hostname or IPv4 address").Action(c.Address.Set).StringVar(&c.Address.Value) - c.CmdClause.Flag("port", "The port number").Action(c.Port.Set).UintVar(&c.Port.Value) - c.CmdClause.Flag("public-key", "A PGP public key that Fastly will use to encrypt your log files before writing them to disk").Action(c.PublicKey.Set).StringVar(&c.PublicKey.Value) - c.CmdClause.Flag("secret-key", "The SSH private key for the server. If both password and secret_key are passed, secret_key will be used in preference").Action(c.SecretKey.Set).StringVar(&c.SecretKey.Value) - c.CmdClause.Flag("ssh-known-hosts", "A list of host keys for all hosts we can connect to over SFTP").Action(c.SSHKnownHosts.Set).StringVar(&c.SSHKnownHosts.Value) - c.CmdClause.Flag("user", "The username for the server").Action(c.User.Set).StringVar(&c.User.Value) - c.CmdClause.Flag("password", "The password for the server. If both password and secret_key are passed, secret_key will be used in preference").Action(c.Password.Set).StringVar(&c.Password.Value) - c.CmdClause.Flag("path", "The path to upload logs to. The directory must exist on the SFTP server before logs can be saved to it").Action(c.Path.Set).StringVar(&c.Path.Value) - c.CmdClause.Flag("period", "How frequently log files are finalized so they can be available for reading (in seconds, default 3600)").Action(c.Period.Set).UintVar(&c.Period.Value) - c.CmdClause.Flag("format", "Apache style log formatting").Action(c.Format.Set).StringVar(&c.Format.Value) - c.CmdClause.Flag("format-version", "The version of the custom logging format used for the configured endpoint. Can be either 2 (default) or 1").Action(c.FormatVersion.Set).UintVar(&c.FormatVersion.Value) - c.CmdClause.Flag("message-type", "How the message should be formatted. One of: classic (default), loggly, logplex or blank").Action(c.MessageType.Set).StringVar(&c.MessageType.Value) - c.CmdClause.Flag("gzip-level", "What level of GZIP encoding to have when dumping logs (default 0, no compression)").Action(c.GzipLevel.Set).UintVar(&c.GzipLevel.Value) - c.CmdClause.Flag("response-condition", "The name of an existing condition in the configured endpoint, or leave blank to always execute").Action(c.ResponseCondition.Set).StringVar(&c.ResponseCondition.Value) - c.CmdClause.Flag("timestamp-format", `strftime specified timestamp formatting (default "%Y-%m-%dT%H:%M:%S.000")`).Action(c.TimestampFormat.Set).StringVar(&c.TimestampFormat.Value) - c.CmdClause.Flag("placement", "Where in the generated VCL the logging call should be placed, overriding any format_version default. Can be none or waf_debug").Action(c.Placement.Set).StringVar(&c.Placement.Value) - c.CmdClause.Flag("compression-codec", `The codec used for compression of your logs. Valid values are zstd, snappy, and gzip. If the specified codec is "gzip", gzip_level will default to 3. To specify a different level, leave compression_codec blank and explicitly set the level using gzip_level. Specifying both compression_codec and gzip_level in the same API request will result in an error.`).Action(c.CompressionCodec.Set).StringVar(&c.CompressionCodec.Value) - - return &c -} - -// createInput transforms values parsed from CLI flags into an object to be used by the API client library. -func (c *UpdateCommand) createInput() (*fastly.UpdateSFTPInput, error) { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return nil, errors.ErrNoServiceID - } - - input := fastly.UpdateSFTPInput{ - ServiceID: serviceID, - ServiceVersion: c.Version, - Name: c.EndpointName, - } - - if c.NewName.WasSet { - input.NewName = fastly.String(c.NewName.Value) - } - - if c.Address.WasSet { - input.Address = fastly.String(c.Address.Value) - } - - if c.Port.WasSet { - input.Port = fastly.Uint(c.Port.Value) - } - - if c.Password.WasSet { - input.Password = fastly.String(c.Password.Value) - } - - if c.PublicKey.WasSet { - input.PublicKey = fastly.String(c.PublicKey.Value) - } - - if c.SecretKey.WasSet { - input.SecretKey = fastly.String(c.SecretKey.Value) - } - - if c.SSHKnownHosts.WasSet { - input.SSHKnownHosts = fastly.String(c.SSHKnownHosts.Value) - } - - if c.User.WasSet { - input.User = fastly.String(c.User.Value) - } - - if c.Path.WasSet { - input.Path = fastly.String(c.Path.Value) - } - - if c.Period.WasSet { - input.Period = fastly.Uint(c.Period.Value) - } - - if c.Format.WasSet { - input.Format = fastly.String(c.Format.Value) - } - - if c.FormatVersion.WasSet { - input.FormatVersion = fastly.Uint(c.FormatVersion.Value) - } - - if c.GzipLevel.WasSet { - input.GzipLevel = fastly.Uint(c.GzipLevel.Value) - } - - if c.MessageType.WasSet { - input.MessageType = fastly.String(c.MessageType.Value) - } - - if c.ResponseCondition.WasSet { - input.ResponseCondition = fastly.String(c.ResponseCondition.Value) - } - - if c.TimestampFormat.WasSet { - input.TimestampFormat = fastly.String(c.TimestampFormat.Value) - } - - if c.Placement.WasSet { - input.Placement = fastly.String(c.Placement.Value) - } - - if c.CompressionCodec.WasSet { - input.CompressionCodec = fastly.String(c.CompressionCodec.Value) - } - - return &input, nil -} - -// Exec invokes the application logic for the command. -func (c *UpdateCommand) Exec(in io.Reader, out io.Writer) error { - input, err := c.createInput() - if err != nil { - return err - } - - sftp, err := c.Globals.Client.UpdateSFTP(input) - if err != nil { - return err - } - - text.Success(out, "Updated SFTP logging endpoint %s (service %s version %d)", sftp.Name, sftp.ServiceID, sftp.ServiceVersion) - return nil -} diff --git a/pkg/logging/splunk/create.go b/pkg/logging/splunk/create.go deleted file mode 100644 index a7129f62c..000000000 --- a/pkg/logging/splunk/create.go +++ /dev/null @@ -1,130 +0,0 @@ -package splunk - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// CreateCommand calls the Fastly API to create a Splunk logging endpoint. -type CreateCommand struct { - common.Base - manifest manifest.Data - - // required - EndpointName string // Can't shadow common.Base method Name(). - Version int - URL string - - // optional - TLSHostname common.OptionalString - TLSCACert common.OptionalString - TLSClientCert common.OptionalString - TLSClientKey common.OptionalString - Format common.OptionalString - FormatVersion common.OptionalUint - ResponseCondition common.OptionalString - Token common.OptionalString - TimestampFormat common.OptionalString - Placement common.OptionalString -} - -// NewCreateCommand returns a usable command registered under the parent. -func NewCreateCommand(parent common.Registerer, globals *config.Data) *CreateCommand { - var c CreateCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("create", "Create a Splunk logging endpoint on a Fastly service version").Alias("add") - - c.CmdClause.Flag("name", "The name of the Splunk logging object. Used as a primary key for API access").Short('n').Required().StringVar(&c.EndpointName) - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Version) - c.CmdClause.Flag("url", "The URL to POST to").Required().StringVar(&c.URL) - - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("tls-ca-cert", "A secure certificate to authenticate the server with. Must be in PEM format").Action(c.TLSCACert.Set).StringVar(&c.TLSCACert.Value) - c.CmdClause.Flag("tls-hostname", "The hostname used to verify the server's certificate. It can either be the Common Name or a Subject Alternative Name (SAN)").Action(c.TLSHostname.Set).StringVar(&c.TLSHostname.Value) - c.CmdClause.Flag("tls-client-cert", "The client certificate used to make authenticated requests. Must be in PEM format").Action(c.TLSClientCert.Set).StringVar(&c.TLSClientCert.Value) - c.CmdClause.Flag("tls-client-key", "The client private key used to make authenticated requests. Must be in PEM format").Action(c.TLSClientKey.Set).StringVar(&c.TLSClientKey.Value) - c.CmdClause.Flag("format", "Apache style log formatting").Action(c.Format.Set).StringVar(&c.Format.Value) - c.CmdClause.Flag("format-version", "The version of the custom logging format used for the configured endpoint. Can be either 2 (default) or 1").Action(c.FormatVersion.Set).UintVar(&c.FormatVersion.Value) - c.CmdClause.Flag("response-condition", "The name of an existing condition in the configured endpoint, or leave blank to always execute").Action(c.ResponseCondition.Set).StringVar(&c.ResponseCondition.Value) - c.CmdClause.Flag("placement", "Where in the generated VCL the logging call should be placed, overriding any format_version default. Can be none or waf_debug").Action(c.Placement.Set).StringVar(&c.Placement.Value) - c.CmdClause.Flag("auth-token", "A Splunk token for use in posting logs over HTTP to your collector").Action(c.Token.Set).StringVar(&c.Token.Value) - - return &c -} - -// createInput transforms values parsed from CLI flags into an object to be used by the API client library. -func (c *CreateCommand) createInput() (*fastly.CreateSplunkInput, error) { - var input fastly.CreateSplunkInput - - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return nil, errors.ErrNoServiceID - } - - input.ServiceID = serviceID - input.ServiceVersion = c.Version - input.Name = c.EndpointName - input.URL = c.URL - - if c.TLSHostname.WasSet { - input.TLSHostname = c.TLSHostname.Value - } - - if c.TLSCACert.WasSet { - input.TLSCACert = c.TLSCACert.Value - } - - if c.TLSClientCert.WasSet { - input.TLSClientCert = c.TLSClientCert.Value - } - - if c.TLSClientKey.WasSet { - input.TLSClientKey = c.TLSClientKey.Value - } - - if c.Format.WasSet { - input.Format = c.Format.Value - } - - if c.FormatVersion.WasSet { - input.FormatVersion = c.FormatVersion.Value - } - - if c.ResponseCondition.WasSet { - input.ResponseCondition = c.ResponseCondition.Value - } - - if c.Token.WasSet { - input.Token = c.Token.Value - } - - if c.Placement.WasSet { - input.Placement = c.Placement.Value - } - - return &input, nil -} - -// Exec invokes the application logic for the command. -func (c *CreateCommand) Exec(in io.Reader, out io.Writer) error { - input, err := c.createInput() - if err != nil { - return err - } - - d, err := c.Globals.Client.CreateSplunk(input) - if err != nil { - return err - } - - text.Success(out, "Created Splunk logging endpoint %s (service %s version %d)", d.Name, d.ServiceID, d.ServiceVersion) - return nil -} diff --git a/pkg/logging/splunk/delete.go b/pkg/logging/splunk/delete.go deleted file mode 100644 index aac0d77b8..000000000 --- a/pkg/logging/splunk/delete.go +++ /dev/null @@ -1,51 +0,0 @@ -package splunk - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// DeleteCommand calls the Fastly API to delete a Splunk logging endpoint. -type DeleteCommand struct { - common.Base - manifest manifest.Data - Input fastly.DeleteSplunkInput -} - -// NewDeleteCommand returns a usable command registered under the parent. -func NewDeleteCommand(parent common.Registerer, globals *config.Data) *DeleteCommand { - var c DeleteCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("delete", "Delete a Splunk logging endpoint on a Fastly service version").Alias("remove") - - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Input.ServiceVersion) - c.CmdClause.Flag("name", "The name of the Splunk logging object").Short('n').Required().StringVar(&c.Input.Name) - - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - - return &c -} - -// Exec invokes the application logic for the command. -func (c *DeleteCommand) Exec(in io.Reader, out io.Writer) error { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - c.Input.ServiceID = serviceID - - if err := c.Globals.Client.DeleteSplunk(&c.Input); err != nil { - return err - } - - text.Success(out, "Deleted Splunk logging endpoint %s (service %s version %d)", c.Input.Name, c.Input.ServiceID, c.Input.ServiceVersion) - return nil -} diff --git a/pkg/logging/splunk/describe.go b/pkg/logging/splunk/describe.go deleted file mode 100644 index cd25fc045..000000000 --- a/pkg/logging/splunk/describe.go +++ /dev/null @@ -1,62 +0,0 @@ -package splunk - -import ( - "fmt" - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/go-fastly/v3/fastly" -) - -// DescribeCommand calls the Fastly API to describe a Splunk logging endpoint. -type DescribeCommand struct { - common.Base - manifest manifest.Data - Input fastly.GetSplunkInput -} - -// NewDescribeCommand returns a usable command registered under the parent. -func NewDescribeCommand(parent common.Registerer, globals *config.Data) *DescribeCommand { - var c DescribeCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("describe", "Show detailed information about a Splunk logging endpoint on a Fastly service version").Alias("get") - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Input.ServiceVersion) - c.CmdClause.Flag("name", "The name of the Splunk logging object").Short('n').Required().StringVar(&c.Input.Name) - return &c -} - -// Exec invokes the application logic for the command. -func (c *DescribeCommand) Exec(in io.Reader, out io.Writer) error { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - c.Input.ServiceID = serviceID - - splunk, err := c.Globals.Client.GetSplunk(&c.Input) - if err != nil { - return err - } - - fmt.Fprintf(out, "Service ID: %s\n", splunk.ServiceID) - fmt.Fprintf(out, "Version: %d\n", splunk.ServiceVersion) - fmt.Fprintf(out, "Name: %s\n", splunk.Name) - fmt.Fprintf(out, "URL: %s\n", splunk.URL) - fmt.Fprintf(out, "Token: %s\n", splunk.Token) - fmt.Fprintf(out, "TLS CA certificate: %s\n", splunk.TLSCACert) - fmt.Fprintf(out, "TLS hostname: %s\n", splunk.TLSHostname) - fmt.Fprintf(out, "TLS client certificate: %s\n", splunk.TLSClientCert) - fmt.Fprintf(out, "TLS client key: %s\n", splunk.TLSClientKey) - fmt.Fprintf(out, "Format: %s\n", splunk.Format) - fmt.Fprintf(out, "Format version: %d\n", splunk.FormatVersion) - fmt.Fprintf(out, "Response condition: %s\n", splunk.ResponseCondition) - fmt.Fprintf(out, "Placement: %s\n", splunk.Placement) - - return nil -} diff --git a/pkg/logging/splunk/list.go b/pkg/logging/splunk/list.go deleted file mode 100644 index 5acbe64bf..000000000 --- a/pkg/logging/splunk/list.go +++ /dev/null @@ -1,78 +0,0 @@ -package splunk - -import ( - "fmt" - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// ListCommand calls the Fastly API to list Splunk logging endpoints. -type ListCommand struct { - common.Base - manifest manifest.Data - Input fastly.ListSplunksInput -} - -// NewListCommand returns a usable command registered under the parent. -func NewListCommand(parent common.Registerer, globals *config.Data) *ListCommand { - var c ListCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("list", "List Splunk endpoints on a Fastly service version") - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Input.ServiceVersion) - return &c -} - -// Exec invokes the application logic for the command. -func (c *ListCommand) Exec(in io.Reader, out io.Writer) error { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - c.Input.ServiceID = serviceID - - splunks, err := c.Globals.Client.ListSplunks(&c.Input) - if err != nil { - return err - } - - if !c.Globals.Verbose() { - tw := text.NewTable(out) - tw.AddHeader("SERVICE", "VERSION", "NAME") - for _, splunk := range splunks { - tw.AddLine(splunk.ServiceID, splunk.ServiceVersion, splunk.Name) - } - tw.Print() - return nil - } - - fmt.Fprintf(out, "Service ID: %s\n", c.Input.ServiceID) - fmt.Fprintf(out, "Version: %d\n", c.Input.ServiceVersion) - for i, splunk := range splunks { - fmt.Fprintf(out, "\tSplunk %d/%d\n", i+1, len(splunks)) - fmt.Fprintf(out, "\t\tService ID: %s\n", splunk.ServiceID) - fmt.Fprintf(out, "\t\tVersion: %d\n", splunk.ServiceVersion) - fmt.Fprintf(out, "\t\tName: %s\n", splunk.Name) - fmt.Fprintf(out, "\t\tURL: %s\n", splunk.URL) - fmt.Fprintf(out, "\t\tToken: %s\n", splunk.Token) - fmt.Fprintf(out, "\t\tTLS CA certificate: %s\n", splunk.TLSCACert) - fmt.Fprintf(out, "\t\tTLS hostname: %s\n", splunk.TLSHostname) - fmt.Fprintf(out, "\t\tTLS client certificate: %s\n", splunk.TLSClientCert) - fmt.Fprintf(out, "\t\tTLS client key: %s\n", splunk.TLSClientKey) - fmt.Fprintf(out, "\t\tFormat: %s\n", splunk.Format) - fmt.Fprintf(out, "\t\tFormat version: %d\n", splunk.FormatVersion) - fmt.Fprintf(out, "\t\tResponse condition: %s\n", splunk.ResponseCondition) - fmt.Fprintf(out, "\t\tPlacement: %s\n", splunk.Placement) - } - fmt.Fprintln(out) - - return nil -} diff --git a/pkg/logging/splunk/root.go b/pkg/logging/splunk/root.go deleted file mode 100644 index b9a7e48cf..000000000 --- a/pkg/logging/splunk/root.go +++ /dev/null @@ -1,28 +0,0 @@ -package splunk - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/config" -) - -// RootCommand is the parent command for all subcommands in this package. -// It should be installed under the primary root command. -type RootCommand struct { - common.Base - // no flags -} - -// NewRootCommand returns a new command registered in the parent. -func NewRootCommand(parent common.Registerer, globals *config.Data) *RootCommand { - var c RootCommand - c.Globals = globals - c.CmdClause = parent.Command("splunk", "Manipulate Fastly service version Splunk logging endpoints") - return &c -} - -// Exec implements the command interface. -func (c *RootCommand) Exec(in io.Reader, out io.Writer) error { - panic("unreachable") -} diff --git a/pkg/logging/splunk/splunk_integration_test.go b/pkg/logging/splunk/splunk_integration_test.go deleted file mode 100644 index 1d9e6afa3..000000000 --- a/pkg/logging/splunk/splunk_integration_test.go +++ /dev/null @@ -1,400 +0,0 @@ -package splunk_test - -import ( - "bytes" - "errors" - "io" - "net/http" - "strings" - "testing" - - "github.com/fastly/cli/pkg/app" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/mock" - "github.com/fastly/cli/pkg/testutil" - "github.com/fastly/cli/pkg/update" - "github.com/fastly/go-fastly/v3/fastly" -) - -func TestSplunkCreate(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "splunk", "create", "--service-id", "123", "--version", "1", "--name", "log"}, - wantError: "error parsing arguments: required flag --url not provided", - }, - { - args: []string{"logging", "splunk", "create", "--service-id", "123", "--version", "1", "--name", "log", "--url", "example.com"}, - api: mock.API{CreateSplunkFn: createSplunkOK}, - wantOutput: "Created Splunk logging endpoint log (service 123 version 1)", - }, - { - args: []string{"logging", "splunk", "create", "--service-id", "123", "--version", "1", "--name", "log", "--url", "example.com"}, - api: mock.API{CreateSplunkFn: createSplunkError}, - wantError: errTest.Error(), - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertStringContains(t, out.String(), testcase.wantOutput) - }) - } -} - -func TestSplunkList(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "splunk", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListSplunksFn: listSplunksOK}, - wantOutput: listSplunksShortOutput, - }, - { - args: []string{"logging", "splunk", "list", "--service-id", "123", "--version", "1", "--verbose"}, - api: mock.API{ListSplunksFn: listSplunksOK}, - wantOutput: listSplunksVerboseOutput, - }, - { - args: []string{"logging", "splunk", "list", "--service-id", "123", "--version", "1", "-v"}, - api: mock.API{ListSplunksFn: listSplunksOK}, - wantOutput: listSplunksVerboseOutput, - }, - { - args: []string{"logging", "splunk", "--verbose", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListSplunksFn: listSplunksOK}, - wantOutput: listSplunksVerboseOutput, - }, - { - args: []string{"logging", "-v", "splunk", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListSplunksFn: listSplunksOK}, - wantOutput: listSplunksVerboseOutput, - }, - { - args: []string{"logging", "splunk", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListSplunksFn: listSplunksError}, - wantError: errTest.Error(), - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertString(t, testcase.wantOutput, out.String()) - }) - } -} - -func TestSplunkDescribe(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "splunk", "describe", "--service-id", "123", "--version", "1"}, - wantError: "error parsing arguments: required flag --name not provided", - }, - { - args: []string{"logging", "splunk", "describe", "--service-id", "123", "--version", "1", "--name", "logs"}, - api: mock.API{GetSplunkFn: getSplunkError}, - wantError: errTest.Error(), - }, - { - args: []string{"logging", "splunk", "describe", "--service-id", "123", "--version", "1", "--name", "logs"}, - api: mock.API{GetSplunkFn: getSplunkOK}, - wantOutput: describeSplunkOutput, - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertString(t, testcase.wantOutput, out.String()) - }) - } -} - -func TestSplunkUpdate(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "splunk", "update", "--service-id", "123", "--version", "1", "--new-name", "log"}, - wantError: "error parsing arguments: required flag --name not provided", - }, - { - args: []string{"logging", "splunk", "update", "--service-id", "123", "--version", "1", "--name", "logs", "--new-name", "log"}, - api: mock.API{UpdateSplunkFn: updateSplunkError}, - wantError: errTest.Error(), - }, - { - args: []string{"logging", "splunk", "update", "--service-id", "123", "--version", "1", "--name", "logs", "--new-name", "log"}, - api: mock.API{UpdateSplunkFn: updateSplunkOK}, - wantOutput: "Updated Splunk logging endpoint log (service 123 version 1)", - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertStringContains(t, out.String(), testcase.wantOutput) - }) - } -} - -func TestSplunkDelete(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "splunk", "delete", "--service-id", "123", "--version", "1"}, - wantError: "error parsing arguments: required flag --name not provided", - }, - { - args: []string{"logging", "splunk", "delete", "--service-id", "123", "--version", "1", "--name", "logs"}, - api: mock.API{DeleteSplunkFn: deleteSplunkError}, - wantError: errTest.Error(), - }, - { - args: []string{"logging", "splunk", "delete", "--service-id", "123", "--version", "1", "--name", "logs"}, - api: mock.API{DeleteSplunkFn: deleteSplunkOK}, - wantOutput: "Deleted Splunk logging endpoint logs (service 123 version 1)", - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertStringContains(t, out.String(), testcase.wantOutput) - }) - } -} - -var errTest = errors.New("fixture error") - -func createSplunkOK(i *fastly.CreateSplunkInput) (*fastly.Splunk, error) { - return &fastly.Splunk{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: i.Name, - }, nil -} - -func createSplunkError(i *fastly.CreateSplunkInput) (*fastly.Splunk, error) { - return nil, errTest -} - -func listSplunksOK(i *fastly.ListSplunksInput) ([]*fastly.Splunk, error) { - return []*fastly.Splunk{ - { - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "logs", - URL: "example.com", - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - ResponseCondition: "Prevent default logging", - Placement: "none", - Token: "tkn", - TLSCACert: "-----BEGIN CERTIFICATE-----foo", - TLSHostname: "example.com", - TLSClientCert: "-----BEGIN CERTIFICATE-----bar", - TLSClientKey: "-----BEGIN PRIVATE KEY-----bar", - }, - { - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "analytics", - URL: "127.0.0.1", - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - ResponseCondition: "Prevent default logging", - Placement: "none", - Token: "tkn1", - TLSCACert: "-----BEGIN CERTIFICATE-----foo", - TLSHostname: "example.com", - TLSClientCert: "-----BEGIN CERTIFICATE-----qux", - TLSClientKey: "-----BEGIN PRIVATE KEY-----qux", - }, - }, nil -} - -func listSplunksError(i *fastly.ListSplunksInput) ([]*fastly.Splunk, error) { - return nil, errTest -} - -var listSplunksShortOutput = strings.TrimSpace(` -SERVICE VERSION NAME -123 1 logs -123 1 analytics -`) + "\n" - -var listSplunksVerboseOutput = strings.TrimSpace(` -Fastly API token not provided -Fastly API endpoint: https://api.fastly.com -Service ID: 123 -Version: 1 - Splunk 1/2 - Service ID: 123 - Version: 1 - Name: logs - URL: example.com - Token: tkn - TLS CA certificate: -----BEGIN CERTIFICATE-----foo - TLS hostname: example.com - TLS client certificate: -----BEGIN CERTIFICATE-----bar - TLS client key: -----BEGIN PRIVATE KEY-----bar - Format: %h %l %u %t "%r" %>s %b - Format version: 2 - Response condition: Prevent default logging - Placement: none - Splunk 2/2 - Service ID: 123 - Version: 1 - Name: analytics - URL: 127.0.0.1 - Token: tkn1 - TLS CA certificate: -----BEGIN CERTIFICATE-----foo - TLS hostname: example.com - TLS client certificate: -----BEGIN CERTIFICATE-----qux - TLS client key: -----BEGIN PRIVATE KEY-----qux - Format: %h %l %u %t "%r" %>s %b - Format version: 2 - Response condition: Prevent default logging - Placement: none -`) + "\n\n" - -func getSplunkOK(i *fastly.GetSplunkInput) (*fastly.Splunk, error) { - return &fastly.Splunk{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "logs", - URL: "example.com", - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - TLSCACert: "-----BEGIN CERTIFICATE-----foo", - TLSHostname: "example.com", - TLSClientCert: "-----BEGIN CERTIFICATE-----bar", - TLSClientKey: "-----BEGIN PRIVATE KEY-----bar", - ResponseCondition: "Prevent default logging", - Placement: "none", - Token: "tkn", - }, nil -} - -func getSplunkError(i *fastly.GetSplunkInput) (*fastly.Splunk, error) { - return nil, errTest -} - -var describeSplunkOutput = strings.TrimSpace(` -Service ID: 123 -Version: 1 -Name: logs -URL: example.com -Token: tkn -TLS CA certificate: -----BEGIN CERTIFICATE-----foo -TLS hostname: example.com -TLS client certificate: -----BEGIN CERTIFICATE-----bar -TLS client key: -----BEGIN PRIVATE KEY-----bar -Format: %h %l %u %t "%r" %>s %b -Format version: 2 -Response condition: Prevent default logging -Placement: none -`) + "\n" - -func updateSplunkOK(i *fastly.UpdateSplunkInput) (*fastly.Splunk, error) { - return &fastly.Splunk{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "log", - URL: "example.com", - Token: "tkn", - TLSCACert: "-----BEGIN CERTIFICATE-----foo", - TLSHostname: "example.com", - TLSClientCert: "-----BEGIN CERTIFICATE-----bar", - TLSClientKey: "-----BEGIN PRIVATE KEY-----bar", - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - ResponseCondition: "Prevent default logging", - Placement: "none", - }, nil -} - -func updateSplunkError(i *fastly.UpdateSplunkInput) (*fastly.Splunk, error) { - return nil, errTest -} - -func deleteSplunkOK(i *fastly.DeleteSplunkInput) error { - return nil -} - -func deleteSplunkError(i *fastly.DeleteSplunkInput) error { - return errTest -} diff --git a/pkg/logging/splunk/splunk_test.go b/pkg/logging/splunk/splunk_test.go deleted file mode 100644 index fc69e19d5..000000000 --- a/pkg/logging/splunk/splunk_test.go +++ /dev/null @@ -1,207 +0,0 @@ -package splunk - -import ( - "testing" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/mock" - "github.com/fastly/cli/pkg/testutil" - "github.com/fastly/go-fastly/v3/fastly" -) - -func TestCreateSplunkInput(t *testing.T) { - for _, testcase := range []struct { - name string - cmd *CreateCommand - want *fastly.CreateSplunkInput - wantError string - }{ - { - name: "required values set flag serviceID", - cmd: createCommandRequired(), - want: &fastly.CreateSplunkInput{ - ServiceID: "123", - ServiceVersion: 2, - Name: "log", - URL: "example.com", - }, - }, - { - name: "all values set flag serviceID", - cmd: createCommandAll(), - want: &fastly.CreateSplunkInput{ - ServiceID: "123", - ServiceVersion: 2, - Name: "log", - URL: "example.com", - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - ResponseCondition: "Prevent default logging", - Placement: "none", - Token: "tkn", - TLSCACert: "-----BEGIN CERTIFICATE-----foo", - TLSHostname: "example.com", - TLSClientCert: "-----BEGIN CERTIFICATE-----bar", - TLSClientKey: "-----BEGIN PRIVATE KEY-----bar", - }, - }, - { - name: "error missing serviceID", - cmd: createCommandMissingServiceID(), - want: nil, - wantError: errors.ErrNoServiceID.Error(), - }, - } { - t.Run(testcase.name, func(t *testing.T) { - have, err := testcase.cmd.createInput() - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertEqual(t, testcase.want, have) - }) - } -} - -func TestUpdateSplunkInput(t *testing.T) { - for _, testcase := range []struct { - name string - cmd *UpdateCommand - api mock.API - want *fastly.UpdateSplunkInput - wantError string - }{ - { - name: "no updates", - cmd: updateCommandNoUpdates(), - api: mock.API{GetSplunkFn: getSplunkOK}, - want: &fastly.UpdateSplunkInput{ - ServiceID: "123", - ServiceVersion: 2, - Name: "log", - }, - }, - { - name: "all values set flag serviceID", - cmd: updateCommandAll(), - api: mock.API{GetSplunkFn: getSplunkOK}, - want: &fastly.UpdateSplunkInput{ - ServiceID: "123", - ServiceVersion: 2, - Name: "log", - NewName: fastly.String("new1"), - URL: fastly.String("new2"), - Format: fastly.String("new3"), - FormatVersion: fastly.Uint(3), - ResponseCondition: fastly.String("new4"), - Placement: fastly.String("new5"), - Token: fastly.String("new6"), - TLSCACert: fastly.String("new7"), - TLSHostname: fastly.String("new8"), - TLSClientCert: fastly.String("new9"), - TLSClientKey: fastly.String("new10"), - }, - }, - { - name: "error missing serviceID", - cmd: updateCommandMissingServiceID(), - want: nil, - wantError: errors.ErrNoServiceID.Error(), - }, - } { - t.Run(testcase.name, func(t *testing.T) { - testcase.cmd.Base.Globals.Client = testcase.api - - have, err := testcase.cmd.createInput() - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertEqual(t, testcase.want, have) - }) - } -} - -func createCommandRequired() *CreateCommand { - return &CreateCommand{ - manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, - EndpointName: "log", - Version: 2, - URL: "example.com", - } -} - -func createCommandAll() *CreateCommand { - return &CreateCommand{ - manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, - EndpointName: "log", - Version: 2, - URL: "example.com", - Format: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: `%h %l %u %t "%r" %>s %b`}, - FormatVersion: common.OptionalUint{Optional: common.Optional{WasSet: true}, Value: 2}, - TimestampFormat: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "%Y-%m-%dT%H:%M:%S.000"}, - ResponseCondition: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "Prevent default logging"}, - Placement: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "none"}, - Token: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "tkn"}, - TLSCACert: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "-----BEGIN CERTIFICATE-----foo"}, - TLSHostname: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "example.com"}, - TLSClientCert: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "-----BEGIN CERTIFICATE-----bar"}, - TLSClientKey: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "-----BEGIN PRIVATE KEY-----bar"}, - } -} - -func createCommandMissingServiceID() *CreateCommand { - res := createCommandAll() - res.manifest = manifest.Data{} - return res -} - -func updateCommandNoUpdates() *UpdateCommand { - return &UpdateCommand{ - Base: common.Base{Globals: &config.Data{Client: nil}}, - manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, - EndpointName: "log", - Version: 2, - } -} - -func updateCommandAll() *UpdateCommand { - return &UpdateCommand{ - Base: common.Base{Globals: &config.Data{Client: nil}}, - manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, - EndpointName: "log", - Version: 2, - NewName: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new1"}, - URL: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new2"}, - Format: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new3"}, - FormatVersion: common.OptionalUint{Optional: common.Optional{WasSet: true}, Value: 3}, - ResponseCondition: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new4"}, - Placement: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new5"}, - Token: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new6"}, - TLSCACert: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new7"}, - TLSHostname: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new8"}, - TLSClientCert: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new9"}, - TLSClientKey: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new10"}, - } -} - -func updateCommandMissingServiceID() *UpdateCommand { - res := updateCommandAll() - res.manifest = manifest.Data{} - return res -} - -func getSplunkOK(i *fastly.GetSplunkInput) (*fastly.Splunk, error) { - return &fastly.Splunk{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "logs", - URL: "example.com", - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - ResponseCondition: "Prevent default logging", - Placement: "none", - Token: "tkn", - TLSCACert: "-----BEGIN CERTIFICATE-----foo", - TLSHostname: "example.com", - TLSClientCert: "-----BEGIN CERTIFICATE-----bar", - TLSClientKey: "-----BEGIN PRIVATE KEY-----bar", - }, nil -} diff --git a/pkg/logging/splunk/update.go b/pkg/logging/splunk/update.go deleted file mode 100644 index 3bea342c4..000000000 --- a/pkg/logging/splunk/update.go +++ /dev/null @@ -1,140 +0,0 @@ -package splunk - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// UpdateCommand calls the Fastly API to update a Splunk logging endpoint. -type UpdateCommand struct { - common.Base - manifest manifest.Data - - // required - EndpointName string // Can't shadow common.Base method Name(). - Version int - - // optional - NewName common.OptionalString - URL common.OptionalString - Format common.OptionalString - FormatVersion common.OptionalUint - ResponseCondition common.OptionalString - Placement common.OptionalString - Token common.OptionalString - TLSCACert common.OptionalString - TLSHostname common.OptionalString - TLSClientCert common.OptionalString - TLSClientKey common.OptionalString -} - -// NewUpdateCommand returns a usable command registered under the parent. -func NewUpdateCommand(parent common.Registerer, globals *config.Data) *UpdateCommand { - var c UpdateCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - - c.CmdClause = parent.Command("update", "Update a Splunk logging endpoint on a Fastly service version") - - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Version) - c.CmdClause.Flag("name", "The name of the Splunk logging object").Short('n').Required().StringVar(&c.EndpointName) - - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("new-name", "New name of the Splunk logging object").Action(c.NewName.Set).StringVar(&c.NewName.Value) - c.CmdClause.Flag("url", "The URL to POST to.").Action(c.URL.Set).StringVar(&c.URL.Value) - c.CmdClause.Flag("tls-ca-cert", "A secure certificate to authenticate the server with. Must be in PEM format").Action(c.TLSCACert.Set).StringVar(&c.TLSCACert.Value) - c.CmdClause.Flag("tls-hostname", "The hostname used to verify the server's certificate. It can either be the Common Name or a Subject Alternative Name (SAN)").Action(c.TLSHostname.Set).StringVar(&c.TLSHostname.Value) - c.CmdClause.Flag("tls-client-cert", "The client certificate used to make authenticated requests. Must be in PEM format").Action(c.TLSClientCert.Set).StringVar(&c.TLSClientCert.Value) - c.CmdClause.Flag("tls-client-key", "The client private key used to make authenticated requests. Must be in PEM format").Action(c.TLSClientKey.Set).StringVar(&c.TLSClientKey.Value) - c.CmdClause.Flag("format", "Apache style log formatting").Action(c.Format.Set).StringVar(&c.Format.Value) - c.CmdClause.Flag("format-version", "The version of the custom logging format used for the configured endpoint. Can be either 2 (default) or 1").Action(c.FormatVersion.Set).UintVar(&c.FormatVersion.Value) - c.CmdClause.Flag("response-condition", "The name of an existing condition in the configured endpoint, or leave blank to always execute").Action(c.ResponseCondition.Set).StringVar(&c.ResponseCondition.Value) - c.CmdClause.Flag("placement", " Where in the generated VCL the logging call should be placed, overriding any format_version default. Can be none or waf_debug. This field is not required and has no default value").Action(c.Placement.Set).StringVar(&c.Placement.Value) - c.CmdClause.Flag("auth-token", "").Action(c.Token.Set).StringVar(&c.Token.Value) - - return &c -} - -// createInput transforms values parsed from CLI flags into an object to be used by the API client library. -func (c *UpdateCommand) createInput() (*fastly.UpdateSplunkInput, error) { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return nil, errors.ErrNoServiceID - } - - input := fastly.UpdateSplunkInput{ - ServiceID: serviceID, - ServiceVersion: c.Version, - Name: c.EndpointName, - } - - // Set new values if set by user. - if c.NewName.WasSet { - input.NewName = fastly.String(c.NewName.Value) - } - - if c.URL.WasSet { - input.URL = fastly.String(c.URL.Value) - } - - if c.Format.WasSet { - input.Format = fastly.String(c.Format.Value) - } - - if c.FormatVersion.WasSet { - input.FormatVersion = fastly.Uint(c.FormatVersion.Value) - } - - if c.ResponseCondition.WasSet { - input.ResponseCondition = fastly.String(c.ResponseCondition.Value) - } - - if c.Placement.WasSet { - input.Placement = fastly.String(c.Placement.Value) - } - - if c.Token.WasSet { - input.Token = fastly.String(c.Token.Value) - } - - if c.TLSCACert.WasSet { - input.TLSCACert = fastly.String(c.TLSCACert.Value) - } - - if c.TLSHostname.WasSet { - input.TLSHostname = fastly.String(c.TLSHostname.Value) - } - - if c.TLSClientCert.WasSet { - input.TLSClientCert = fastly.String(c.TLSClientCert.Value) - } - - if c.TLSClientKey.WasSet { - input.TLSClientKey = fastly.String(c.TLSClientKey.Value) - } - - return &input, nil -} - -// Exec invokes the application logic for the command. -func (c *UpdateCommand) Exec(in io.Reader, out io.Writer) error { - input, err := c.createInput() - if err != nil { - return err - } - - splunk, err := c.Globals.Client.UpdateSplunk(input) - if err != nil { - return err - } - - text.Success(out, "Updated Splunk logging endpoint %s (service %s version %d)", splunk.Name, splunk.ServiceID, splunk.ServiceVersion) - return nil -} diff --git a/pkg/logging/sumologic/create.go b/pkg/logging/sumologic/create.go deleted file mode 100644 index 023923597..000000000 --- a/pkg/logging/sumologic/create.go +++ /dev/null @@ -1,105 +0,0 @@ -package sumologic - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// CreateCommand calls the Fastly API to create a Sumologic logging endpoint. -type CreateCommand struct { - common.Base - manifest manifest.Data - - // required - EndpointName string // Can't shadow common.Base method Name(). - Version int - URL string - - // optional - Format common.OptionalString - FormatVersion common.OptionalInt - ResponseCondition common.OptionalString - Placement common.OptionalString - MessageType common.OptionalString -} - -// NewCreateCommand returns a usable command registered under the parent. -func NewCreateCommand(parent common.Registerer, globals *config.Data) *CreateCommand { - var c CreateCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("create", "Create a Sumologic logging endpoint on a Fastly service version").Alias("add") - - c.CmdClause.Flag("name", "The name of the Sumologic logging object. Used as a primary key for API access").Short('n').Required().StringVar(&c.EndpointName) - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Version) - c.CmdClause.Flag("url", "The URL to POST to").Required().StringVar(&c.URL) - - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("format", "Apache style log formatting").Action(c.Format.Set).StringVar(&c.Format.Value) - c.CmdClause.Flag("format-version", "The version of the custom logging format used for the configured endpoint. Can be either 2 (the default, version 2 log format) or 1 (the version 1 log format). The logging call gets placed by default in vcl_log if format_version is set to 2 and in vcl_deliver if format_version is set to 1").Action(c.FormatVersion.Set).IntVar(&c.FormatVersion.Value) - c.CmdClause.Flag("response-condition", "The name of an existing condition in the configured endpoint, or leave blank to always execute").Action(c.ResponseCondition.Set).StringVar(&c.ResponseCondition.Value) - c.CmdClause.Flag("message-type", "How the message should be formatted. One of: classic (default), loggly, logplex or blank").Action(c.MessageType.Set).StringVar(&c.MessageType.Value) - c.CmdClause.Flag("placement", "Where in the generated VCL the logging call should be placed, overriding any format_version default. Can be none or waf_debug. This field is not required and has no default value").Action(c.Placement.Set).StringVar(&c.Placement.Value) - - return &c -} - -// createInput transforms values parsed from CLI flags into an object to be used by the API client library. -func (c *CreateCommand) createInput() (*fastly.CreateSumologicInput, error) { - var input fastly.CreateSumologicInput - - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return nil, errors.ErrNoServiceID - } - - input.ServiceID = serviceID - input.ServiceVersion = c.Version - input.Name = c.EndpointName - input.URL = c.URL - - if c.Format.WasSet { - input.Format = c.Format.Value - } - - if c.FormatVersion.WasSet { - input.FormatVersion = c.FormatVersion.Value - } - - if c.ResponseCondition.WasSet { - input.ResponseCondition = c.ResponseCondition.Value - } - - if c.Placement.WasSet { - input.Placement = c.Placement.Value - } - - if c.MessageType.WasSet { - input.MessageType = c.MessageType.Value - } - - return &input, nil -} - -// Exec invokes the application logic for the command. -func (c *CreateCommand) Exec(in io.Reader, out io.Writer) error { - input, err := c.createInput() - if err != nil { - return err - } - - d, err := c.Globals.Client.CreateSumologic(input) - if err != nil { - return err - } - - text.Success(out, "Created Sumologic logging endpoint %s (service %s version %d)", d.Name, d.ServiceID, d.ServiceVersion) - return nil -} diff --git a/pkg/logging/sumologic/delete.go b/pkg/logging/sumologic/delete.go deleted file mode 100644 index 56d9a2015..000000000 --- a/pkg/logging/sumologic/delete.go +++ /dev/null @@ -1,51 +0,0 @@ -package sumologic - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// DeleteCommand calls the Fastly API to delete a Sumologic logging endpoint. -type DeleteCommand struct { - common.Base - manifest manifest.Data - Input fastly.DeleteSumologicInput -} - -// NewDeleteCommand returns a usable command registered under the parent. -func NewDeleteCommand(parent common.Registerer, globals *config.Data) *DeleteCommand { - var c DeleteCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("delete", "Delete a Sumologic logging endpoint on a Fastly service version").Alias("remove") - - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Input.ServiceVersion) - c.CmdClause.Flag("name", "The name of the Sumologic logging object").Short('n').Required().StringVar(&c.Input.Name) - - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - - return &c -} - -// Exec invokes the application logic for the command. -func (c *DeleteCommand) Exec(in io.Reader, out io.Writer) error { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - c.Input.ServiceID = serviceID - - if err := c.Globals.Client.DeleteSumologic(&c.Input); err != nil { - return err - } - - text.Success(out, "Deleted Sumologic logging endpoint %s (service %s version %d)", c.Input.Name, c.Input.ServiceID, c.Input.ServiceVersion) - return nil -} diff --git a/pkg/logging/sumologic/describe.go b/pkg/logging/sumologic/describe.go deleted file mode 100644 index 8bc7be68c..000000000 --- a/pkg/logging/sumologic/describe.go +++ /dev/null @@ -1,58 +0,0 @@ -package sumologic - -import ( - "fmt" - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/go-fastly/v3/fastly" -) - -// DescribeCommand calls the Fastly API to describe a Sumologic logging endpoint. -type DescribeCommand struct { - common.Base - manifest manifest.Data - Input fastly.GetSumologicInput -} - -// NewDescribeCommand returns a usable command registered under the parent. -func NewDescribeCommand(parent common.Registerer, globals *config.Data) *DescribeCommand { - var c DescribeCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("describe", "Show detailed information about a Sumologic logging endpoint on a Fastly service version").Alias("get") - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Input.ServiceVersion) - c.CmdClause.Flag("name", "The name of the Sumologic logging object").Short('n').Required().StringVar(&c.Input.Name) - return &c -} - -// Exec invokes the application logic for the command. -func (c *DescribeCommand) Exec(in io.Reader, out io.Writer) error { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - c.Input.ServiceID = serviceID - - sumologic, err := c.Globals.Client.GetSumologic(&c.Input) - if err != nil { - return err - } - - fmt.Fprintf(out, "Service ID: %s\n", sumologic.ServiceID) - fmt.Fprintf(out, "Version: %d\n", sumologic.ServiceVersion) - fmt.Fprintf(out, "Name: %s\n", sumologic.Name) - fmt.Fprintf(out, "URL: %s\n", sumologic.URL) - fmt.Fprintf(out, "Format: %s\n", sumologic.Format) - fmt.Fprintf(out, "Format version: %d\n", sumologic.FormatVersion) - fmt.Fprintf(out, "Response condition: %s\n", sumologic.ResponseCondition) - fmt.Fprintf(out, "Message type: %s\n", sumologic.MessageType) - fmt.Fprintf(out, "Placement: %s\n", sumologic.Placement) - - return nil -} diff --git a/pkg/logging/sumologic/list.go b/pkg/logging/sumologic/list.go deleted file mode 100644 index 81ba44aaa..000000000 --- a/pkg/logging/sumologic/list.go +++ /dev/null @@ -1,74 +0,0 @@ -package sumologic - -import ( - "fmt" - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// ListCommand calls the Fastly API to list Sumologic logging endpoints. -type ListCommand struct { - common.Base - manifest manifest.Data - Input fastly.ListSumologicsInput -} - -// NewListCommand returns a usable command registered under the parent. -func NewListCommand(parent common.Registerer, globals *config.Data) *ListCommand { - var c ListCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("list", "List Sumologic endpoints on a Fastly service version") - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Input.ServiceVersion) - return &c -} - -// Exec invokes the application logic for the command. -func (c *ListCommand) Exec(in io.Reader, out io.Writer) error { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - c.Input.ServiceID = serviceID - - sumologics, err := c.Globals.Client.ListSumologics(&c.Input) - if err != nil { - return err - } - - if !c.Globals.Verbose() { - tw := text.NewTable(out) - tw.AddHeader("SERVICE", "VERSION", "NAME") - for _, sumologic := range sumologics { - tw.AddLine(sumologic.ServiceID, sumologic.ServiceVersion, sumologic.Name) - } - tw.Print() - return nil - } - - fmt.Fprintf(out, "Service ID: %s\n", c.Input.ServiceID) - fmt.Fprintf(out, "Version: %d\n", c.Input.ServiceVersion) - for i, sumologic := range sumologics { - fmt.Fprintf(out, "\tSumologic %d/%d\n", i+1, len(sumologics)) - fmt.Fprintf(out, "\t\tService ID: %s\n", sumologic.ServiceID) - fmt.Fprintf(out, "\t\tVersion: %d\n", sumologic.ServiceVersion) - fmt.Fprintf(out, "\t\tName: %s\n", sumologic.Name) - fmt.Fprintf(out, "\t\tURL: %s\n", sumologic.URL) - fmt.Fprintf(out, "\t\tFormat: %s\n", sumologic.Format) - fmt.Fprintf(out, "\t\tFormat version: %d\n", sumologic.FormatVersion) - fmt.Fprintf(out, "\t\tResponse condition: %s\n", sumologic.ResponseCondition) - fmt.Fprintf(out, "\t\tMessage type: %s\n", sumologic.MessageType) - fmt.Fprintf(out, "\t\tPlacement: %s\n", sumologic.Placement) - } - fmt.Fprintln(out) - - return nil -} diff --git a/pkg/logging/sumologic/root.go b/pkg/logging/sumologic/root.go deleted file mode 100644 index 6adb67b13..000000000 --- a/pkg/logging/sumologic/root.go +++ /dev/null @@ -1,28 +0,0 @@ -package sumologic - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/config" -) - -// RootCommand is the parent command for all subcommands in this package. -// It should be installed under the primary root command. -type RootCommand struct { - common.Base - // no flags -} - -// NewRootCommand returns a new command registered in the parent. -func NewRootCommand(parent common.Registerer, globals *config.Data) *RootCommand { - var c RootCommand - c.Globals = globals - c.CmdClause = parent.Command("sumologic", "Manipulate Fastly service version Sumologic logging endpoints") - return &c -} - -// Exec implements the command interface. -func (c *RootCommand) Exec(in io.Reader, out io.Writer) error { - panic("unreachable") -} diff --git a/pkg/logging/sumologic/sumologic_integration_test.go b/pkg/logging/sumologic/sumologic_integration_test.go deleted file mode 100644 index e25e5546f..000000000 --- a/pkg/logging/sumologic/sumologic_integration_test.go +++ /dev/null @@ -1,372 +0,0 @@ -package sumologic_test - -import ( - "bytes" - "errors" - "io" - "net/http" - "strings" - "testing" - - "github.com/fastly/cli/pkg/app" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/mock" - "github.com/fastly/cli/pkg/testutil" - "github.com/fastly/cli/pkg/update" - "github.com/fastly/go-fastly/v3/fastly" -) - -func TestSumologicCreate(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "sumologic", "create", "--service-id", "123", "--version", "1", "--name", "log"}, - wantError: "error parsing arguments: required flag --url not provided", - }, - { - args: []string{"logging", "sumologic", "create", "--service-id", "123", "--version", "1", "--name", "log", "--url", "example.com"}, - api: mock.API{CreateSumologicFn: createSumologicOK}, - wantOutput: "Created Sumologic logging endpoint log (service 123 version 1)", - }, - { - args: []string{"logging", "sumologic", "create", "--service-id", "123", "--version", "1", "--name", "log", "--url", "example.com"}, - api: mock.API{CreateSumologicFn: createSumologicError}, - wantError: errTest.Error(), - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertStringContains(t, out.String(), testcase.wantOutput) - }) - } -} - -func TestSumologicList(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "sumologic", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListSumologicsFn: listSumologicsOK}, - wantOutput: listSumologicsShortOutput, - }, - { - args: []string{"logging", "sumologic", "list", "--service-id", "123", "--version", "1", "--verbose"}, - api: mock.API{ListSumologicsFn: listSumologicsOK}, - wantOutput: listSumologicsVerboseOutput, - }, - { - args: []string{"logging", "sumologic", "list", "--service-id", "123", "--version", "1", "-v"}, - api: mock.API{ListSumologicsFn: listSumologicsOK}, - wantOutput: listSumologicsVerboseOutput, - }, - { - args: []string{"logging", "sumologic", "--verbose", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListSumologicsFn: listSumologicsOK}, - wantOutput: listSumologicsVerboseOutput, - }, - { - args: []string{"logging", "-v", "sumologic", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListSumologicsFn: listSumologicsOK}, - wantOutput: listSumologicsVerboseOutput, - }, - { - args: []string{"logging", "sumologic", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListSumologicsFn: listSumologicsError}, - wantError: errTest.Error(), - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertString(t, testcase.wantOutput, out.String()) - }) - } -} - -func TestSumologicDescribe(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "sumologic", "describe", "--service-id", "123", "--version", "1"}, - wantError: "error parsing arguments: required flag --name not provided", - }, - { - args: []string{"logging", "sumologic", "describe", "--service-id", "123", "--version", "1", "--name", "logs"}, - api: mock.API{GetSumologicFn: getSumologicError}, - wantError: errTest.Error(), - }, - { - args: []string{"logging", "sumologic", "describe", "--service-id", "123", "--version", "1", "--name", "logs"}, - api: mock.API{GetSumologicFn: getSumologicOK}, - wantOutput: describeSumologicOutput, - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertString(t, testcase.wantOutput, out.String()) - }) - } -} - -func TestSumologicUpdate(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "sumologic", "update", "--service-id", "123", "--version", "1", "--new-name", "log"}, - wantError: "error parsing arguments: required flag --name not provided", - }, - { - args: []string{"logging", "sumologic", "update", "--service-id", "123", "--version", "1", "--name", "logs", "--new-name", "log"}, - api: mock.API{UpdateSumologicFn: updateSumologicError}, - wantError: errTest.Error(), - }, - { - args: []string{"logging", "sumologic", "update", "--service-id", "123", "--version", "1", "--name", "logs", "--new-name", "log"}, - api: mock.API{UpdateSumologicFn: updateSumologicOK}, - wantOutput: "Updated Sumologic logging endpoint log (service 123 version 1)", - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertStringContains(t, out.String(), testcase.wantOutput) - }) - } -} - -func TestSumologicDelete(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "sumologic", "delete", "--service-id", "123", "--version", "1"}, - wantError: "error parsing arguments: required flag --name not provided", - }, - { - args: []string{"logging", "sumologic", "delete", "--service-id", "123", "--version", "1", "--name", "logs"}, - api: mock.API{DeleteSumologicFn: deleteSumologicError}, - wantError: errTest.Error(), - }, - { - args: []string{"logging", "sumologic", "delete", "--service-id", "123", "--version", "1", "--name", "logs"}, - api: mock.API{DeleteSumologicFn: deleteSumologicOK}, - wantOutput: "Deleted Sumologic logging endpoint logs (service 123 version 1)", - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertStringContains(t, out.String(), testcase.wantOutput) - }) - } -} - -var errTest = errors.New("fixture error") - -func createSumologicOK(i *fastly.CreateSumologicInput) (*fastly.Sumologic, error) { - return &fastly.Sumologic{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: i.Name, - }, nil -} - -func createSumologicError(i *fastly.CreateSumologicInput) (*fastly.Sumologic, error) { - return nil, errTest -} - -func listSumologicsOK(i *fastly.ListSumologicsInput) ([]*fastly.Sumologic, error) { - return []*fastly.Sumologic{ - { - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "logs", - URL: "example.com", - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - MessageType: "classic", - ResponseCondition: "Prevent default logging", - Placement: "none", - }, - { - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "analytics", - URL: "bar.com", - Format: `%h %l %u %t "%r" %>s %b`, - ResponseCondition: "Prevent default logging", - MessageType: "classic", - FormatVersion: 2, - Placement: "none", - }, - }, nil -} - -func listSumologicsError(i *fastly.ListSumologicsInput) ([]*fastly.Sumologic, error) { - return nil, errTest -} - -var listSumologicsShortOutput = strings.TrimSpace(` -SERVICE VERSION NAME -123 1 logs -123 1 analytics -`) + "\n" - -var listSumologicsVerboseOutput = strings.TrimSpace(` -Fastly API token not provided -Fastly API endpoint: https://api.fastly.com -Service ID: 123 -Version: 1 - Sumologic 1/2 - Service ID: 123 - Version: 1 - Name: logs - URL: example.com - Format: %h %l %u %t "%r" %>s %b - Format version: 2 - Response condition: Prevent default logging - Message type: classic - Placement: none - Sumologic 2/2 - Service ID: 123 - Version: 1 - Name: analytics - URL: bar.com - Format: %h %l %u %t "%r" %>s %b - Format version: 2 - Response condition: Prevent default logging - Message type: classic - Placement: none -`) + "\n\n" - -func getSumologicOK(i *fastly.GetSumologicInput) (*fastly.Sumologic, error) { - return &fastly.Sumologic{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "logs", - URL: "example.com", - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - MessageType: "classic", - ResponseCondition: "Prevent default logging", - Placement: "none", - }, nil -} - -func getSumologicError(i *fastly.GetSumologicInput) (*fastly.Sumologic, error) { - return nil, errTest -} - -var describeSumologicOutput = strings.TrimSpace(` -Service ID: 123 -Version: 1 -Name: logs -URL: example.com -Format: %h %l %u %t "%r" %>s %b -Format version: 2 -Response condition: Prevent default logging -Message type: classic -Placement: none -`) + "\n" - -func updateSumologicOK(i *fastly.UpdateSumologicInput) (*fastly.Sumologic, error) { - return &fastly.Sumologic{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "log", - URL: "example.com", - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - MessageType: "classic", - ResponseCondition: "Prevent default logging", - Placement: "none", - }, nil -} - -func updateSumologicError(i *fastly.UpdateSumologicInput) (*fastly.Sumologic, error) { - return nil, errTest -} - -func deleteSumologicOK(i *fastly.DeleteSumologicInput) error { - return nil -} - -func deleteSumologicError(i *fastly.DeleteSumologicInput) error { - return errTest -} diff --git a/pkg/logging/sumologic/sumologic_test.go b/pkg/logging/sumologic/sumologic_test.go deleted file mode 100644 index c87415ff8..000000000 --- a/pkg/logging/sumologic/sumologic_test.go +++ /dev/null @@ -1,186 +0,0 @@ -package sumologic - -import ( - "testing" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/mock" - "github.com/fastly/cli/pkg/testutil" - "github.com/fastly/go-fastly/v3/fastly" -) - -func TestCreateSumologicInput(t *testing.T) { - for _, testcase := range []struct { - name string - cmd *CreateCommand - want *fastly.CreateSumologicInput - wantError string - }{ - { - name: "required values set flag serviceID", - cmd: createCommandRequired(), - want: &fastly.CreateSumologicInput{ - ServiceID: "123", - ServiceVersion: 2, - Name: "log", - URL: "example.com", - }, - }, - { - name: "all values set flag serviceID", - cmd: createCommandOK(), - want: &fastly.CreateSumologicInput{ - ServiceID: "123", - ServiceVersion: 2, - Name: "log", - URL: "example.com", - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - ResponseCondition: "Prevent default logging", - Placement: "none", - MessageType: "classic", - }, - }, - { - name: "error missing serviceID", - cmd: createCommandMissingServiceID(), - want: nil, - wantError: errors.ErrNoServiceID.Error(), - }, - } { - t.Run(testcase.name, func(t *testing.T) { - have, err := testcase.cmd.createInput() - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertEqual(t, testcase.want, have) - }) - } -} - -func TestUpdateSumologicInput(t *testing.T) { - for _, testcase := range []struct { - name string - cmd *UpdateCommand - api mock.API - want *fastly.UpdateSumologicInput - wantError string - }{ - { - name: "no updates", - cmd: updateCommandNoUpdates(), - api: mock.API{GetSumologicFn: getSumologicOK}, - want: &fastly.UpdateSumologicInput{ - ServiceID: "123", - ServiceVersion: 2, - Name: "log", - }, - }, - { - name: "all values set flag serviceID", - cmd: updateCommandAll(), - api: mock.API{GetSumologicFn: getSumologicOK}, - want: &fastly.UpdateSumologicInput{ - ServiceID: "123", - ServiceVersion: 2, - Name: "log", - NewName: fastly.String("new1"), - URL: fastly.String("new2"), - Format: fastly.String("new3"), - FormatVersion: fastly.Int(3), - ResponseCondition: fastly.String("new4"), - Placement: fastly.String("new5"), - MessageType: fastly.String("new6"), - }, - }, - { - name: "error missing serviceID", - cmd: updateCommandMissingServiceID(), - want: nil, - wantError: errors.ErrNoServiceID.Error(), - }, - } { - t.Run(testcase.name, func(t *testing.T) { - testcase.cmd.Base.Globals.Client = testcase.api - - have, err := testcase.cmd.createInput() - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertEqual(t, testcase.want, have) - }) - } -} - -func createCommandOK() *CreateCommand { - return &CreateCommand{ - manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, - EndpointName: "log", - URL: "example.com", - Version: 2, - Format: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: `%h %l %u %t "%r" %>s %b`}, - FormatVersion: common.OptionalInt{Optional: common.Optional{WasSet: true}, Value: 2}, - ResponseCondition: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "Prevent default logging"}, - Placement: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "none"}, - MessageType: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "classic"}, - } -} - -func createCommandRequired() *CreateCommand { - return &CreateCommand{ - manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, - EndpointName: "log", - URL: "example.com", - Version: 2, - } -} - -func createCommandMissingServiceID() *CreateCommand { - res := createCommandOK() - res.manifest = manifest.Data{} - return res -} - -func updateCommandNoUpdates() *UpdateCommand { - return &UpdateCommand{ - Base: common.Base{Globals: &config.Data{Client: nil}}, - manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, - EndpointName: "log", - Version: 2, - } -} - -func updateCommandAll() *UpdateCommand { - return &UpdateCommand{ - Base: common.Base{Globals: &config.Data{Client: nil}}, - manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, - EndpointName: "log", - Version: 2, - NewName: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new1"}, - URL: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new2"}, - Format: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new3"}, - FormatVersion: common.OptionalInt{Optional: common.Optional{WasSet: true}, Value: 3}, - ResponseCondition: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new4"}, - Placement: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new5"}, - MessageType: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new6"}, - } -} - -func updateCommandMissingServiceID() *UpdateCommand { - res := updateCommandAll() - res.manifest = manifest.Data{} - return res -} - -func getSumologicOK(i *fastly.GetSumologicInput) (*fastly.Sumologic, error) { - return &fastly.Sumologic{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "logs", - URL: "example.com", - Format: `%h %l %u %t "%r" %>s %b`, - ResponseCondition: "Prevent default logging", - MessageType: "classic", - FormatVersion: 2, - Placement: "none", - }, nil -} diff --git a/pkg/logging/sumologic/update.go b/pkg/logging/sumologic/update.go deleted file mode 100644 index 0381dab51..000000000 --- a/pkg/logging/sumologic/update.go +++ /dev/null @@ -1,115 +0,0 @@ -package sumologic - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// UpdateCommand calls the Fastly API to update a Sumologic logging endpoint. -type UpdateCommand struct { - common.Base - manifest manifest.Data - - // required - EndpointName string // Can't shadow common.Base method Name(). - Version int - - // optional - NewName common.OptionalString - URL common.OptionalString - Format common.OptionalString - ResponseCondition common.OptionalString - MessageType common.OptionalString - FormatVersion common.OptionalInt // Inconsistent with other logging endpoints, but remaining as int to avoid breaking changes in fastly/go-fastly. - Placement common.OptionalString -} - -// NewUpdateCommand returns a usable command registered under the parent. -func NewUpdateCommand(parent common.Registerer, globals *config.Data) *UpdateCommand { - var c UpdateCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - - c.CmdClause = parent.Command("update", "Update a Sumologic logging endpoint on a Fastly service version") - - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Version) - c.CmdClause.Flag("name", "The name of the Sumologic logging object").Short('n').Required().StringVar(&c.EndpointName) - - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("new-name", "New name of the Sumologic logging object").Action(c.NewName.Set).StringVar(&c.NewName.Value) - c.CmdClause.Flag("url", "The URL to POST to").Action(c.URL.Set).StringVar(&c.URL.Value) - c.CmdClause.Flag("format", "Apache style log formatting").Action(c.Format.Set).StringVar(&c.Format.Value) - c.CmdClause.Flag("format-version", "The version of the custom logging format used for the configured endpoint. Can be either 2 (the default, version 2 log format) or 1 (the version 1 log format). The logging call gets placed by default in vcl_log if format_version is set to 2 and in vcl_deliver if format_version is set to 1").Action(c.FormatVersion.Set).IntVar(&c.FormatVersion.Value) - c.CmdClause.Flag("response-condition", "The name of an existing condition in the configured endpoint, or leave blank to always execute").Action(c.ResponseCondition.Set).StringVar(&c.ResponseCondition.Value) - c.CmdClause.Flag("message-type", "How the message should be formatted. One of: classic (default), loggly, logplex or blank").Action(c.MessageType.Set).StringVar(&c.MessageType.Value) - c.CmdClause.Flag("placement", "Where in the generated VCL the logging call should be placed, overriding any format_version default. Can be none or waf_debug. This field is not required and has no default value").Action(c.Placement.Set).StringVar(&c.Placement.Value) - - return &c -} - -// createInput transforms values parsed from CLI flags into an object to be used by the API client library. -func (c *UpdateCommand) createInput() (*fastly.UpdateSumologicInput, error) { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return nil, errors.ErrNoServiceID - } - - input := fastly.UpdateSumologicInput{ - ServiceID: serviceID, - ServiceVersion: c.Version, - Name: c.EndpointName, - } - - // Set new values if set by user. - if c.NewName.WasSet { - input.NewName = fastly.String(c.NewName.Value) - } - - if c.URL.WasSet { - input.URL = fastly.String(c.URL.Value) - } - - if c.Format.WasSet { - input.Format = fastly.String(c.Format.Value) - } - - if c.ResponseCondition.WasSet { - input.ResponseCondition = fastly.String(c.ResponseCondition.Value) - } - - if c.MessageType.WasSet { - input.MessageType = fastly.String(c.MessageType.Value) - } - - if c.FormatVersion.WasSet { - input.FormatVersion = fastly.Int(c.FormatVersion.Value) - } - - if c.Placement.WasSet { - input.Placement = fastly.String(c.Placement.Value) - } - - return &input, nil -} - -// Exec invokes the application logic for the command. -func (c *UpdateCommand) Exec(in io.Reader, out io.Writer) error { - input, err := c.createInput() - if err != nil { - return err - } - sumologic, err := c.Globals.Client.UpdateSumologic(input) - if err != nil { - return err - } - - text.Success(out, "Updated Sumologic logging endpoint %s (service %s version %d)", sumologic.Name, sumologic.ServiceID, sumologic.ServiceVersion) - return nil -} diff --git a/pkg/logging/syslog/create.go b/pkg/logging/syslog/create.go deleted file mode 100644 index 721fccbbc..000000000 --- a/pkg/logging/syslog/create.go +++ /dev/null @@ -1,147 +0,0 @@ -package syslog - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// CreateCommand calls the Fastly API to create a Syslog logging endpoint. -type CreateCommand struct { - common.Base - manifest manifest.Data - - // required - EndpointName string // Can't shadow common.Base method Name(). - Version int - Address string - - // optional - Port common.OptionalUint - UseTLS common.OptionalBool - Token common.OptionalString - TLSCACert common.OptionalString - TLSClientCert common.OptionalString - TLSClientKey common.OptionalString - TLSHostname common.OptionalString - MessageType common.OptionalString - Format common.OptionalString - FormatVersion common.OptionalUint - Placement common.OptionalString - ResponseCondition common.OptionalString -} - -// NewCreateCommand returns a usable command registered under the parent. -func NewCreateCommand(parent common.Registerer, globals *config.Data) *CreateCommand { - var c CreateCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("create", "Create a Syslog logging endpoint on a Fastly service version").Alias("add") - - c.CmdClause.Flag("name", "The name of the Syslog logging object. Used as a primary key for API access").Short('n').Required().StringVar(&c.EndpointName) - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Version) - c.CmdClause.Flag("address", "A hostname or IPv4 address").Required().StringVar(&c.Address) - - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("port", "The port number").Action(c.Port.Set).UintVar(&c.Port.Value) - c.CmdClause.Flag("use-tls", "Whether to use TLS for secure logging. Can be either true or false").Action(c.UseTLS.Set).BoolVar(&c.UseTLS.Value) - c.CmdClause.Flag("tls-ca-cert", "A secure certificate to authenticate the server with. Must be in PEM format").Action(c.TLSCACert.Set).StringVar(&c.TLSCACert.Value) - c.CmdClause.Flag("tls-hostname", "Used during the TLS handshake to validate the certificate").Action(c.TLSHostname.Set).StringVar(&c.TLSHostname.Value) - c.CmdClause.Flag("tls-client-cert", "The client certificate used to make authenticated requests. Must be in PEM format").Action(c.TLSClientCert.Set).StringVar(&c.TLSClientCert.Value) - c.CmdClause.Flag("tls-client-key", "The client private key used to make authenticated requests. Must be in PEM format").Action(c.TLSClientKey.Set).StringVar(&c.TLSClientKey.Value) - c.CmdClause.Flag("auth-token", "Whether to prepend each message with a specific token").Action(c.Token.Set).StringVar(&c.Token.Value) - c.CmdClause.Flag("format", "Apache style log formatting").Action(c.Format.Set).StringVar(&c.Format.Value) - c.CmdClause.Flag("format-version", "The version of the custom logging format used for the configured endpoint. Can be either 2 (default) or 1").Action(c.FormatVersion.Set).UintVar(&c.FormatVersion.Value) - c.CmdClause.Flag("message-type", "How the message should be formatted. One of: classic (default), loggly, logplex or blank").Action(c.MessageType.Set).StringVar(&c.MessageType.Value) - c.CmdClause.Flag("response-condition", "The name of an existing condition in the configured endpoint, or leave blank to always execute").Action(c.ResponseCondition.Set).StringVar(&c.ResponseCondition.Value) - c.CmdClause.Flag("placement", "Where in the generated VCL the logging call should be placed, overriding any format_version default. Can be none or waf_debug").Action(c.Placement.Set).StringVar(&c.Placement.Value) - - return &c -} - -// createInput transforms values parsed from CLI flags into an object to be used by the API client library. -func (c *CreateCommand) createInput() (*fastly.CreateSyslogInput, error) { - var input fastly.CreateSyslogInput - - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return nil, errors.ErrNoServiceID - } - - input.ServiceID = serviceID - input.Name = c.EndpointName - input.ServiceVersion = c.Version - input.Address = c.Address - - if c.Port.WasSet { - input.Port = c.Port.Value - } - - if c.UseTLS.WasSet { - input.UseTLS = fastly.Compatibool(c.UseTLS.Value) - } - - if c.TLSCACert.WasSet { - input.TLSCACert = c.TLSCACert.Value - } - - if c.TLSHostname.WasSet { - input.TLSHostname = c.TLSHostname.Value - } - - if c.TLSClientCert.WasSet { - input.TLSClientCert = c.TLSClientCert.Value - } - - if c.TLSClientKey.WasSet { - input.TLSClientKey = c.TLSClientKey.Value - } - - if c.Token.WasSet { - input.Token = c.Token.Value - } - - if c.Format.WasSet { - input.Format = c.Format.Value - } - - if c.FormatVersion.WasSet { - input.FormatVersion = c.FormatVersion.Value - } - - if c.MessageType.WasSet { - input.MessageType = c.MessageType.Value - } - - if c.ResponseCondition.WasSet { - input.ResponseCondition = c.ResponseCondition.Value - } - - if c.Placement.WasSet { - input.Placement = c.Placement.Value - } - - return &input, nil -} - -// Exec invokes the application logic for the command. -func (c *CreateCommand) Exec(in io.Reader, out io.Writer) error { - input, err := c.createInput() - if err != nil { - return err - } - - d, err := c.Globals.Client.CreateSyslog(input) - if err != nil { - return err - } - - text.Success(out, "Created Syslog logging endpoint %s (service %s version %d)", d.Name, d.ServiceID, d.ServiceVersion) - return nil -} diff --git a/pkg/logging/syslog/delete.go b/pkg/logging/syslog/delete.go deleted file mode 100644 index 721b7b3a9..000000000 --- a/pkg/logging/syslog/delete.go +++ /dev/null @@ -1,50 +0,0 @@ -package syslog - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// DeleteCommand calls the Fastly API to delete a Syslog logging endpoint. -type DeleteCommand struct { - common.Base - manifest manifest.Data - Input fastly.DeleteSyslogInput -} - -// NewDeleteCommand returns a usable command registered under the parent. -func NewDeleteCommand(parent common.Registerer, globals *config.Data) *DeleteCommand { - var c DeleteCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("delete", "Delete a Syslog logging endpoint on a Fastly service version").Alias("remove") - - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Input.ServiceVersion) - c.CmdClause.Flag("name", "The name of the Syslog logging object").Short('n').Required().StringVar(&c.Input.Name) - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - - return &c -} - -// Exec invokes the application logic for the command. -func (c *DeleteCommand) Exec(in io.Reader, out io.Writer) error { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - c.Input.ServiceID = serviceID - - if err := c.Globals.Client.DeleteSyslog(&c.Input); err != nil { - return err - } - - text.Success(out, "Deleted Syslog logging endpoint %s (service %s version %d)", c.Input.Name, c.Input.ServiceID, c.Input.ServiceVersion) - return nil -} diff --git a/pkg/logging/syslog/describe.go b/pkg/logging/syslog/describe.go deleted file mode 100644 index 69e58d466..000000000 --- a/pkg/logging/syslog/describe.go +++ /dev/null @@ -1,67 +0,0 @@ -package syslog - -import ( - "fmt" - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/go-fastly/v3/fastly" -) - -// DescribeCommand calls the Fastly API to describe a Syslog logging endpoint. -type DescribeCommand struct { - common.Base - manifest manifest.Data - Input fastly.GetSyslogInput -} - -// NewDescribeCommand returns a usable command registered under the parent. -func NewDescribeCommand(parent common.Registerer, globals *config.Data) *DescribeCommand { - var c DescribeCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("describe", "Show detailed information about a Syslog logging endpoint on a Fastly service version").Alias("get") - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Input.ServiceVersion) - c.CmdClause.Flag("name", "The name of the Syslog logging object").Short('n').Required().StringVar(&c.Input.Name) - return &c -} - -// Exec invokes the application logic for the command. -func (c *DescribeCommand) Exec(in io.Reader, out io.Writer) error { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - c.Input.ServiceID = serviceID - - syslog, err := c.Globals.Client.GetSyslog(&c.Input) - if err != nil { - return err - } - - fmt.Fprintf(out, "Service ID: %s\n", syslog.ServiceID) - fmt.Fprintf(out, "Version: %d\n", syslog.ServiceVersion) - fmt.Fprintf(out, "Name: %s\n", syslog.Name) - fmt.Fprintf(out, "Address: %s\n", syslog.Address) - fmt.Fprintf(out, "Hostname: %s\n", syslog.Hostname) - fmt.Fprintf(out, "Port: %d\n", syslog.Port) - fmt.Fprintf(out, "Use TLS: %t\n", syslog.UseTLS) - fmt.Fprintf(out, "IPV4: %s\n", syslog.IPV4) - fmt.Fprintf(out, "TLS CA certificate: %s\n", syslog.TLSCACert) - fmt.Fprintf(out, "TLS hostname: %s\n", syslog.TLSHostname) - fmt.Fprintf(out, "TLS client certificate: %s\n", syslog.TLSClientCert) - fmt.Fprintf(out, "TLS client key: %s\n", syslog.TLSClientKey) - fmt.Fprintf(out, "Token: %s\n", syslog.Token) - fmt.Fprintf(out, "Format: %s\n", syslog.Format) - fmt.Fprintf(out, "Format version: %d\n", syslog.FormatVersion) - fmt.Fprintf(out, "Message type: %s\n", syslog.MessageType) - fmt.Fprintf(out, "Response condition: %s\n", syslog.ResponseCondition) - fmt.Fprintf(out, "Placement: %s\n", syslog.Placement) - - return nil -} diff --git a/pkg/logging/syslog/list.go b/pkg/logging/syslog/list.go deleted file mode 100644 index 8056f639a..000000000 --- a/pkg/logging/syslog/list.go +++ /dev/null @@ -1,83 +0,0 @@ -package syslog - -import ( - "fmt" - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// ListCommand calls the Fastly API to list Syslog logging endpoints. -type ListCommand struct { - common.Base - manifest manifest.Data - Input fastly.ListSyslogsInput -} - -// NewListCommand returns a usable command registered under the parent. -func NewListCommand(parent common.Registerer, globals *config.Data) *ListCommand { - var c ListCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("list", "List Syslog endpoints on a Fastly service version") - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Input.ServiceVersion) - return &c -} - -// Exec invokes the application logic for the command. -func (c *ListCommand) Exec(in io.Reader, out io.Writer) error { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - c.Input.ServiceID = serviceID - - syslogs, err := c.Globals.Client.ListSyslogs(&c.Input) - if err != nil { - return err - } - - if !c.Globals.Verbose() { - tw := text.NewTable(out) - tw.AddHeader("SERVICE", "VERSION", "NAME") - for _, syslog := range syslogs { - tw.AddLine(syslog.ServiceID, syslog.ServiceVersion, syslog.Name) - } - tw.Print() - return nil - } - - fmt.Fprintf(out, "Service ID: %s\n", c.Input.ServiceID) - fmt.Fprintf(out, "Version: %d\n", c.Input.ServiceVersion) - for i, syslog := range syslogs { - fmt.Fprintf(out, "\tSyslog %d/%d\n", i+1, len(syslogs)) - fmt.Fprintf(out, "\t\tService ID: %s\n", syslog.ServiceID) - fmt.Fprintf(out, "\t\tVersion: %d\n", syslog.ServiceVersion) - fmt.Fprintf(out, "\t\tName: %s\n", syslog.Name) - fmt.Fprintf(out, "\t\tAddress: %s\n", syslog.Address) - fmt.Fprintf(out, "\t\tHostname: %s\n", syslog.Hostname) - fmt.Fprintf(out, "\t\tPort: %d\n", syslog.Port) - fmt.Fprintf(out, "\t\tUse TLS: %t\n", syslog.UseTLS) - fmt.Fprintf(out, "\t\tIPV4: %s\n", syslog.IPV4) - fmt.Fprintf(out, "\t\tTLS CA certificate: %s\n", syslog.TLSCACert) - fmt.Fprintf(out, "\t\tTLS hostname: %s\n", syslog.TLSHostname) - fmt.Fprintf(out, "\t\tTLS client certificate: %s\n", syslog.TLSClientCert) - fmt.Fprintf(out, "\t\tTLS client key: %s\n", syslog.TLSClientKey) - fmt.Fprintf(out, "\t\tToken: %s\n", syslog.Token) - fmt.Fprintf(out, "\t\tFormat: %s\n", syslog.Format) - fmt.Fprintf(out, "\t\tFormat version: %d\n", syslog.FormatVersion) - fmt.Fprintf(out, "\t\tMessage type: %s\n", syslog.MessageType) - fmt.Fprintf(out, "\t\tResponse condition: %s\n", syslog.ResponseCondition) - fmt.Fprintf(out, "\t\tPlacement: %s\n", syslog.Placement) - } - fmt.Fprintln(out) - - return nil -} diff --git a/pkg/logging/syslog/root.go b/pkg/logging/syslog/root.go deleted file mode 100644 index b5f87b2d2..000000000 --- a/pkg/logging/syslog/root.go +++ /dev/null @@ -1,28 +0,0 @@ -package syslog - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/config" -) - -// RootCommand is the parent command for all subcommands in this package. -// It should be installed under the primary root command. -type RootCommand struct { - common.Base - // no flags -} - -// NewRootCommand returns a new command registered in the parent. -func NewRootCommand(parent common.Registerer, globals *config.Data) *RootCommand { - var c RootCommand - c.Globals = globals - c.CmdClause = parent.Command("syslog", "Manipulate Fastly service version Syslog logging endpoints") - return &c -} - -// Exec implements the command interface. -func (c *RootCommand) Exec(in io.Reader, out io.Writer) error { - panic("unreachable") -} diff --git a/pkg/logging/syslog/syslog_integration_test.go b/pkg/logging/syslog/syslog_integration_test.go deleted file mode 100644 index aa0b2f9b4..000000000 --- a/pkg/logging/syslog/syslog_integration_test.go +++ /dev/null @@ -1,435 +0,0 @@ -package syslog_test - -import ( - "bytes" - "errors" - "io" - "net/http" - "strings" - "testing" - - "github.com/fastly/cli/pkg/app" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/mock" - "github.com/fastly/cli/pkg/testutil" - "github.com/fastly/cli/pkg/update" - "github.com/fastly/go-fastly/v3/fastly" -) - -func TestSyslogCreate(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "syslog", "create", "--service-id", "123", "--version", "1", "--name", "log"}, - wantError: "error parsing arguments: required flag --address not provided", - }, - { - args: []string{"logging", "syslog", "create", "--service-id", "123", "--version", "1", "--name", "log", "--address", "127.0.0.1"}, - api: mock.API{CreateSyslogFn: createSyslogOK}, - wantOutput: "Created Syslog logging endpoint log (service 123 version 1)", - }, - { - args: []string{"logging", "syslog", "create", "--service-id", "123", "--version", "1", "--name", "log", "--address", "127.0.0.1"}, - api: mock.API{CreateSyslogFn: createSyslogError}, - wantError: errTest.Error(), - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertStringContains(t, out.String(), testcase.wantOutput) - }) - } -} - -func TestSyslogList(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "syslog", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListSyslogsFn: listSyslogsOK}, - wantOutput: listSyslogsShortOutput, - }, - { - args: []string{"logging", "syslog", "list", "--service-id", "123", "--version", "1", "--verbose"}, - api: mock.API{ListSyslogsFn: listSyslogsOK}, - wantOutput: listSyslogsVerboseOutput, - }, - { - args: []string{"logging", "syslog", "list", "--service-id", "123", "--version", "1", "-v"}, - api: mock.API{ListSyslogsFn: listSyslogsOK}, - wantOutput: listSyslogsVerboseOutput, - }, - { - args: []string{"logging", "syslog", "--verbose", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListSyslogsFn: listSyslogsOK}, - wantOutput: listSyslogsVerboseOutput, - }, - { - args: []string{"logging", "-v", "syslog", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListSyslogsFn: listSyslogsOK}, - wantOutput: listSyslogsVerboseOutput, - }, - { - args: []string{"logging", "syslog", "list", "--service-id", "123", "--version", "1"}, - api: mock.API{ListSyslogsFn: listSyslogsError}, - wantError: errTest.Error(), - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertString(t, testcase.wantOutput, out.String()) - }) - } -} - -func TestSyslogDescribe(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "syslog", "describe", "--service-id", "123", "--version", "1"}, - wantError: "error parsing arguments: required flag --name not provided", - }, - { - args: []string{"logging", "syslog", "describe", "--service-id", "123", "--version", "1", "--name", "logs"}, - api: mock.API{GetSyslogFn: getSyslogError}, - wantError: errTest.Error(), - }, - { - args: []string{"logging", "syslog", "describe", "--service-id", "123", "--version", "1", "--name", "logs"}, - api: mock.API{GetSyslogFn: getSyslogOK}, - wantOutput: describeSyslogOutput, - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertString(t, testcase.wantOutput, out.String()) - }) - } -} - -func TestSyslogUpdate(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "syslog", "update", "--service-id", "123", "--version", "1", "--new-name", "log"}, - wantError: "error parsing arguments: required flag --name not provided", - }, - { - args: []string{"logging", "syslog", "update", "--service-id", "123", "--version", "1", "--name", "logs", "--new-name", "log"}, - api: mock.API{UpdateSyslogFn: updateSyslogError}, - wantError: errTest.Error(), - }, - { - args: []string{"logging", "syslog", "update", "--service-id", "123", "--version", "1", "--name", "logs", "--new-name", "log"}, - api: mock.API{UpdateSyslogFn: updateSyslogOK}, - wantOutput: "Updated Syslog logging endpoint log (service 123 version 1)", - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertStringContains(t, out.String(), testcase.wantOutput) - }) - } -} - -func TestSyslogDelete(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"logging", "syslog", "delete", "--service-id", "123", "--version", "1"}, - wantError: "error parsing arguments: required flag --name not provided", - }, - { - args: []string{"logging", "syslog", "delete", "--service-id", "123", "--version", "1", "--name", "logs"}, - api: mock.API{DeleteSyslogFn: deleteSyslogError}, - wantError: errTest.Error(), - }, - { - args: []string{"logging", "syslog", "delete", "--service-id", "123", "--version", "1", "--name", "logs"}, - api: mock.API{DeleteSyslogFn: deleteSyslogOK}, - wantOutput: "Deleted Syslog logging endpoint logs (service 123 version 1)", - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertStringContains(t, out.String(), testcase.wantOutput) - }) - } -} - -var errTest = errors.New("fixture error") - -func createSyslogOK(i *fastly.CreateSyslogInput) (*fastly.Syslog, error) { - return &fastly.Syslog{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: i.Name, - }, nil -} - -func createSyslogError(i *fastly.CreateSyslogInput) (*fastly.Syslog, error) { - return nil, errTest -} - -func listSyslogsOK(i *fastly.ListSyslogsInput) ([]*fastly.Syslog, error) { - return []*fastly.Syslog{ - { - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "logs", - Address: "127.0.0.1", - Hostname: "", - Port: 514, - UseTLS: false, - IPV4: "127.0.0.1", - TLSCACert: "-----BEGIN CERTIFICATE-----foo", - TLSHostname: "example.com", - TLSClientCert: "-----BEGIN CERTIFICATE-----bar", - TLSClientKey: "-----BEGIN PRIVATE KEY-----bar", - Token: "tkn", - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - MessageType: "classic", - ResponseCondition: "Prevent default logging", - Placement: "none", - }, - { - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "analytics", - Address: "example.com", - Hostname: "example.com", - Port: 789, - UseTLS: true, - IPV4: "", - TLSCACert: "-----BEGIN CERTIFICATE-----baz", - TLSHostname: "example.com", - TLSClientCert: "-----BEGIN CERTIFICATE-----qux", - TLSClientKey: "-----BEGIN PRIVATE KEY-----qux", - Token: "tkn", - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - MessageType: "classic", - ResponseCondition: "Prevent default logging", - Placement: "none", - }, - }, nil -} - -func listSyslogsError(i *fastly.ListSyslogsInput) ([]*fastly.Syslog, error) { - return nil, errTest -} - -var listSyslogsShortOutput = strings.TrimSpace(` -SERVICE VERSION NAME -123 1 logs -123 1 analytics -`) + "\n" - -var listSyslogsVerboseOutput = strings.TrimSpace(` -Fastly API token not provided -Fastly API endpoint: https://api.fastly.com -Service ID: 123 -Version: 1 - Syslog 1/2 - Service ID: 123 - Version: 1 - Name: logs - Address: 127.0.0.1 - Hostname: - Port: 514 - Use TLS: false - IPV4: 127.0.0.1 - TLS CA certificate: -----BEGIN CERTIFICATE-----foo - TLS hostname: example.com - TLS client certificate: -----BEGIN CERTIFICATE-----bar - TLS client key: -----BEGIN PRIVATE KEY-----bar - Token: tkn - Format: %h %l %u %t "%r" %>s %b - Format version: 2 - Message type: classic - Response condition: Prevent default logging - Placement: none - Syslog 2/2 - Service ID: 123 - Version: 1 - Name: analytics - Address: example.com - Hostname: example.com - Port: 789 - Use TLS: true - IPV4: - TLS CA certificate: -----BEGIN CERTIFICATE-----baz - TLS hostname: example.com - TLS client certificate: -----BEGIN CERTIFICATE-----qux - TLS client key: -----BEGIN PRIVATE KEY-----qux - Token: tkn - Format: %h %l %u %t "%r" %>s %b - Format version: 2 - Message type: classic - Response condition: Prevent default logging - Placement: none -`) + "\n\n" - -func getSyslogOK(i *fastly.GetSyslogInput) (*fastly.Syslog, error) { - return &fastly.Syslog{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "logs", - Address: "example.com", - Hostname: "example.com", - Port: 514, - UseTLS: true, - IPV4: "", - TLSCACert: "-----BEGIN CERTIFICATE-----foo", - TLSHostname: "example.com", - TLSClientCert: "-----BEGIN CERTIFICATE-----bar", - TLSClientKey: "-----BEGIN PRIVATE KEY-----bar", - Token: "tkn", - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - MessageType: "classic", - ResponseCondition: "Prevent default logging", - Placement: "none", - }, nil -} - -func getSyslogError(i *fastly.GetSyslogInput) (*fastly.Syslog, error) { - return nil, errTest -} - -var describeSyslogOutput = strings.TrimSpace(` -Service ID: 123 -Version: 1 -Name: logs -Address: example.com -Hostname: example.com -Port: 514 -Use TLS: true -IPV4: -TLS CA certificate: -----BEGIN CERTIFICATE-----foo -TLS hostname: example.com -TLS client certificate: -----BEGIN CERTIFICATE-----bar -TLS client key: -----BEGIN PRIVATE KEY-----bar -Token: tkn -Format: %h %l %u %t "%r" %>s %b -Format version: 2 -Message type: classic -Response condition: Prevent default logging -Placement: none -`) + "\n" - -func updateSyslogOK(i *fastly.UpdateSyslogInput) (*fastly.Syslog, error) { - return &fastly.Syslog{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "log", - Address: "example.com", - Hostname: "example.com", - Port: 514, - UseTLS: true, - IPV4: "", - TLSCACert: "-----BEGIN CERTIFICATE-----foo", - TLSHostname: "example.com", - TLSClientCert: "-----BEGIN CERTIFICATE-----bar", - TLSClientKey: "-----BEGIN PRIVATE KEY-----bar", - Token: "tkn", - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - MessageType: "classic", - ResponseCondition: "Prevent default logging", - Placement: "none", - }, nil -} - -func updateSyslogError(i *fastly.UpdateSyslogInput) (*fastly.Syslog, error) { - return nil, errTest -} - -func deleteSyslogOK(i *fastly.DeleteSyslogInput) error { - return nil -} - -func deleteSyslogError(i *fastly.DeleteSyslogInput) error { - return errTest -} diff --git a/pkg/logging/syslog/syslog_test.go b/pkg/logging/syslog/syslog_test.go deleted file mode 100644 index 42495578c..000000000 --- a/pkg/logging/syslog/syslog_test.go +++ /dev/null @@ -1,221 +0,0 @@ -package syslog - -import ( - "testing" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/mock" - "github.com/fastly/cli/pkg/testutil" - "github.com/fastly/go-fastly/v3/fastly" -) - -func TestCreateSyslogInput(t *testing.T) { - for _, testcase := range []struct { - name string - cmd *CreateCommand - want *fastly.CreateSyslogInput - wantError string - }{ - { - name: "required values set flag serviceID", - cmd: createCommandRequired(), - want: &fastly.CreateSyslogInput{ - ServiceID: "123", - ServiceVersion: 2, - Name: "log", - Address: "example.com", - }, - }, - { - name: "all values set flag serviceID", - cmd: createCommandAll(), - want: &fastly.CreateSyslogInput{ - ServiceID: "123", - ServiceVersion: 2, - Name: "log", - Address: "example.com", - Port: 22, - UseTLS: fastly.Compatibool(true), - TLSCACert: "-----BEGIN CERTIFICATE-----foo", - TLSHostname: "example.com", - TLSClientCert: "-----BEGIN CERTIFICATE-----bar", - TLSClientKey: "-----BEGIN PRIVATE KEY-----bar", - Token: "tkn", - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - MessageType: "classic", - ResponseCondition: "Prevent default logging", - Placement: "none", - }, - }, - { - name: "error missing serviceID", - cmd: createCommandMissingServiceID(), - want: nil, - wantError: errors.ErrNoServiceID.Error(), - }, - } { - t.Run(testcase.name, func(t *testing.T) { - have, err := testcase.cmd.createInput() - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertEqual(t, testcase.want, have) - }) - } -} - -func TestUpdateSyslogInput(t *testing.T) { - for _, testcase := range []struct { - name string - cmd *UpdateCommand - api mock.API - want *fastly.UpdateSyslogInput - wantError string - }{ - { - name: "no updates", - cmd: updateCommandNoUpdates(), - api: mock.API{GetSyslogFn: getSyslogOK}, - want: &fastly.UpdateSyslogInput{ - ServiceID: "123", - ServiceVersion: 2, - Name: "log", - }, - }, - { - name: "all values set flag serviceID", - cmd: updateCommandAll(), - api: mock.API{GetSyslogFn: getSyslogOK}, - want: &fastly.UpdateSyslogInput{ - ServiceID: "123", - ServiceVersion: 2, - Name: "log", - NewName: fastly.String("new1"), - Address: fastly.String("new2"), - Port: fastly.Uint(23), - UseTLS: fastly.CBool(false), - TLSCACert: fastly.String("new3"), - TLSHostname: fastly.String("new4"), - TLSClientCert: fastly.String("new5"), - TLSClientKey: fastly.String("new6"), - Token: fastly.String("new7"), - Format: fastly.String("new8"), - FormatVersion: fastly.Uint(3), - MessageType: fastly.String("new9"), - ResponseCondition: fastly.String("new10"), - Placement: fastly.String("new11"), - }, - }, - { - name: "error missing serviceID", - cmd: updateCommandMissingServiceID(), - want: nil, - wantError: errors.ErrNoServiceID.Error(), - }, - } { - t.Run(testcase.name, func(t *testing.T) { - testcase.cmd.Base.Globals.Client = testcase.api - - have, err := testcase.cmd.createInput() - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertEqual(t, testcase.want, have) - }) - } -} - -func createCommandRequired() *CreateCommand { - return &CreateCommand{ - manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, - EndpointName: "log", - Address: "example.com", - Version: 2, - } -} - -func createCommandAll() *CreateCommand { - return &CreateCommand{ - manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, - EndpointName: "log", - Version: 2, - Address: "example.com", - Format: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: `%h %l %u %t "%r" %>s %b`}, - FormatVersion: common.OptionalUint{Optional: common.Optional{WasSet: true}, Value: 2}, - ResponseCondition: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "Prevent default logging"}, - Placement: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "none"}, - Port: common.OptionalUint{Optional: common.Optional{WasSet: true}, Value: 22}, - UseTLS: common.OptionalBool{Optional: common.Optional{WasSet: true}, Value: true}, - TLSCACert: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "-----BEGIN CERTIFICATE-----foo"}, - TLSHostname: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "example.com"}, - TLSClientCert: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "-----BEGIN CERTIFICATE-----bar"}, - TLSClientKey: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "-----BEGIN PRIVATE KEY-----bar"}, - Token: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "tkn"}, - MessageType: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "classic"}, - } -} - -func createCommandMissingServiceID() *CreateCommand { - res := createCommandAll() - res.manifest = manifest.Data{} - return res -} - -func updateCommandNoUpdates() *UpdateCommand { - return &UpdateCommand{ - Base: common.Base{Globals: &config.Data{Client: nil}}, - manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, - EndpointName: "log", - Version: 2, - } -} - -func updateCommandAll() *UpdateCommand { - return &UpdateCommand{ - Base: common.Base{Globals: &config.Data{Client: nil}}, - manifest: manifest.Data{Flag: manifest.Flag{ServiceID: "123"}}, - EndpointName: "log", - Version: 2, - NewName: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new1"}, - Address: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new2"}, - Port: common.OptionalUint{Optional: common.Optional{WasSet: true}, Value: 23}, - UseTLS: common.OptionalBool{Optional: common.Optional{WasSet: true}, Value: false}, - TLSCACert: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new3"}, - TLSHostname: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new4"}, - TLSClientCert: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new5"}, - TLSClientKey: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new6"}, - Token: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new7"}, - Format: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new8"}, - FormatVersion: common.OptionalUint{Optional: common.Optional{WasSet: true}, Value: 3}, - MessageType: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new9"}, - ResponseCondition: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new10"}, - Placement: common.OptionalString{Optional: common.Optional{WasSet: true}, Value: "new11"}, - } -} - -func updateCommandMissingServiceID() *UpdateCommand { - res := updateCommandAll() - res.manifest = manifest.Data{} - return res -} - -func getSyslogOK(i *fastly.GetSyslogInput) (*fastly.Syslog, error) { - return &fastly.Syslog{ - ServiceID: i.ServiceID, - ServiceVersion: i.ServiceVersion, - Name: "logs", - Address: "example.com", - Port: 22, - UseTLS: true, - TLSCACert: "-----BEGIN CERTIFICATE-----foo", - TLSHostname: "example.com", - TLSClientCert: "-----BEGIN CERTIFICATE-----bar", - TLSClientKey: "-----BEGIN PRIVATE KEY-----bar", - Token: "tkn", - Format: `%h %l %u %t "%r" %>s %b`, - FormatVersion: 2, - MessageType: "classic", - ResponseCondition: "Prevent default logging", - Placement: "none", - }, nil -} diff --git a/pkg/logging/syslog/update.go b/pkg/logging/syslog/update.go deleted file mode 100644 index 935a351c6..000000000 --- a/pkg/logging/syslog/update.go +++ /dev/null @@ -1,158 +0,0 @@ -package syslog - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// UpdateCommand calls the Fastly API to update a Syslog logging endpoint. -type UpdateCommand struct { - common.Base - manifest manifest.Data - - // required - EndpointName string - Version int - - // optional - NewName common.OptionalString - Address common.OptionalString - Port common.OptionalUint - UseTLS common.OptionalBool - TLSCACert common.OptionalString - TLSHostname common.OptionalString - TLSClientCert common.OptionalString - TLSClientKey common.OptionalString - Token common.OptionalString - Format common.OptionalString - FormatVersion common.OptionalUint - MessageType common.OptionalString - ResponseCondition common.OptionalString - Placement common.OptionalString -} - -// NewUpdateCommand returns a usable command registered under the parent. -func NewUpdateCommand(parent common.Registerer, globals *config.Data) *UpdateCommand { - var c UpdateCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - - c.CmdClause = parent.Command("update", "Update a Syslog logging endpoint on a Fastly service version") - - c.CmdClause.Flag("version", "Number of service version").Required().IntVar(&c.Version) - c.CmdClause.Flag("name", "The name of the Syslog logging object").Short('n').Required().StringVar(&c.EndpointName) - - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("new-name", "New name of the Syslog logging object").Action(c.NewName.Set).StringVar(&c.NewName.Value) - c.CmdClause.Flag("address", "A hostname or IPv4 address").Action(c.Address.Set).StringVar(&c.Address.Value) - c.CmdClause.Flag("port", "The port number").Action(c.Port.Set).UintVar(&c.Port.Value) - c.CmdClause.Flag("use-tls", "Whether to use TLS for secure logging. Can be either true or false").Action(c.UseTLS.Set).BoolVar(&c.UseTLS.Value) - c.CmdClause.Flag("tls-ca-cert", "A secure certificate to authenticate the server with. Must be in PEM format").Action(c.TLSCACert.Set).StringVar(&c.TLSCACert.Value) - c.CmdClause.Flag("tls-hostname", "Used during the TLS handshake to validate the certificate").Action(c.TLSHostname.Set).StringVar(&c.TLSHostname.Value) - c.CmdClause.Flag("tls-client-cert", "The client certificate used to make authenticated requests. Must be in PEM format").Action(c.TLSClientCert.Set).StringVar(&c.TLSClientCert.Value) - c.CmdClause.Flag("tls-client-key", "The client private key used to make authenticated requests. Must be in PEM format").Action(c.TLSClientKey.Set).StringVar(&c.TLSClientKey.Value) - c.CmdClause.Flag("auth-token", "Whether to prepend each message with a specific token").Action(c.Token.Set).StringVar(&c.Token.Value) - c.CmdClause.Flag("format", "Apache style log formatting").Action(c.Format.Set).StringVar(&c.Format.Value) - c.CmdClause.Flag("format-version", "The version of the custom logging format used for the configured endpoint. Can be either 2 (default) or 1").Action(c.FormatVersion.Set).UintVar(&c.FormatVersion.Value) - c.CmdClause.Flag("message-type", "How the message should be formatted. One of: classic (default), loggly, logplex or blank").Action(c.MessageType.Set).StringVar(&c.MessageType.Value) - c.CmdClause.Flag("response-condition", "The name of an existing condition in the configured endpoint, or leave blank to always execute").Action(c.ResponseCondition.Set).StringVar(&c.ResponseCondition.Value) - c.CmdClause.Flag("placement", "Where in the generated VCL the logging call should be placed, overriding any format_version default. Can be none or waf_debug").Action(c.Placement.Set).StringVar(&c.Placement.Value) - - return &c -} - -// createInput transforms values parsed from CLI flags into an object to be used by the API client library. -func (c *UpdateCommand) createInput() (*fastly.UpdateSyslogInput, error) { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return nil, errors.ErrNoServiceID - } - - input := fastly.UpdateSyslogInput{ - ServiceID: serviceID, - ServiceVersion: c.Version, - Name: c.EndpointName, - } - - // Set new values if set by user. - if c.NewName.WasSet { - input.NewName = fastly.String(c.NewName.Value) - } - - if c.Address.WasSet { - input.Address = fastly.String(c.Address.Value) - } - - if c.Port.WasSet { - input.Port = fastly.Uint(c.Port.Value) - } - - if c.UseTLS.WasSet { - input.UseTLS = fastly.CBool(c.UseTLS.Value) - } - - if c.TLSCACert.WasSet { - input.TLSCACert = fastly.String(c.TLSCACert.Value) - } - - if c.TLSHostname.WasSet { - input.TLSHostname = fastly.String(c.TLSHostname.Value) - } - - if c.TLSClientCert.WasSet { - input.TLSClientCert = fastly.String(c.TLSClientCert.Value) - } - - if c.TLSClientKey.WasSet { - input.TLSClientKey = fastly.String(c.TLSClientKey.Value) - } - - if c.Token.WasSet { - input.Token = fastly.String(c.Token.Value) - } - - if c.Format.WasSet { - input.Format = fastly.String(c.Format.Value) - } - - if c.FormatVersion.WasSet { - input.FormatVersion = fastly.Uint(c.FormatVersion.Value) - } - - if c.MessageType.WasSet { - input.MessageType = fastly.String(c.MessageType.Value) - } - - if c.ResponseCondition.WasSet { - input.ResponseCondition = fastly.String(c.ResponseCondition.Value) - } - - if c.Placement.WasSet { - input.Placement = fastly.String(c.Placement.Value) - } - - return &input, nil -} - -// Exec invokes the application logic for the command. -func (c *UpdateCommand) Exec(in io.Reader, out io.Writer) error { - input, err := c.createInput() - if err != nil { - return err - } - - syslog, err := c.Globals.Client.UpdateSyslog(input) - if err != nil { - return err - } - - text.Success(out, "Updated Syslog logging endpoint %s (service %s version %d)", syslog.Name, syslog.ServiceID, syslog.ServiceVersion) - return nil -} diff --git a/pkg/logs/doc.go b/pkg/logs/doc.go deleted file mode 100644 index 4ad6569f4..000000000 --- a/pkg/logs/doc.go +++ /dev/null @@ -1,2 +0,0 @@ -// Package logs contains commands to view Fastly managed logs. -package logs diff --git a/pkg/logs/root.go b/pkg/logs/root.go deleted file mode 100644 index 8c6ca026f..000000000 --- a/pkg/logs/root.go +++ /dev/null @@ -1,28 +0,0 @@ -package logs - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/config" -) - -// RootCommand is the parent command for all subcommands in this package. -// It should be installed under the primary root command. -type RootCommand struct { - common.Base - // no flags -} - -// NewRootCommand returns a new command registered in the parent. -func NewRootCommand(parent common.Registerer, globals *config.Data) *RootCommand { - var c RootCommand - c.Globals = globals - c.CmdClause = parent.Command("logs", "Compute@Edge Log Tailing") - return &c -} - -// Exec implements the command interface. -func (c *RootCommand) Exec(in io.Reader, out io.Writer) error { - panic("unreachable") -} diff --git a/pkg/logs/tail.go b/pkg/logs/tail.go deleted file mode 100644 index 25ae87de9..000000000 --- a/pkg/logs/tail.go +++ /dev/null @@ -1,619 +0,0 @@ -package logs - -import ( - "bufio" - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - "os" - "os/signal" - "sort" - "strconv" - "strings" - "syscall" - "time" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" - "github.com/tomnomnom/linkheader" -) - -type ( - // TailCommand represents the CLI subcommand for Log Tailing. - TailCommand struct { - common.Base - manifest manifest.Data - Input fastly.CreateManagedLoggingInput - - cfg cfg - - dieCh chan struct{} // channel to end output/printing - batchCh chan Batch // send batches to output loop - doneCh chan struct{} // channel to signal we've reached the end of the run - - hClient *http.Client // TODO: this will go away when GET is in go-fastly - token string // TODO: this will go away when GET is in go-fastly - } - - // cfg holds the configuration parameters passed in through - // command line arguments. - cfg struct { - // path is the full path to fetch - path string - - // from is how far in the past to start showing logs. - from int64 - - // to is when to get logs until. - to int64 - - // sortBuffer is how long to buffer logs from when the cli - // receives them to when the cli prints them. It will sort - // by RequestID for that buffer period. - sortBuffer time.Duration - // searchPadding is how much of a window on either side of - // from and to to use for searching for the beginning or - // through the end timestamps. - searchPadding time.Duration - // stream specifies which of stdout or stderr or both the - // customer wants to consume. - // Undefined == both stderr and stdout. - stream string - } - - // Log defines the message envelope that compute@edge (C@E) wraps the - // user messages in. - Log struct { - // SequenceNum is the message sequence number used to reorder - // messages. - SequenceNum int `json:"sequence_number"` - // RequestTime is the time in microseconds when the request - // was received. - RequestStart int64 `json:"request_start_us"` - // Stream is the C@E stream, either stdout or stderr. - Stream string `json:"stream"` - // RequestID is a UUID representing individual requests to the - // particular WASM service. - RequestID string `json:"id"` - // Message is the actual message body the user wants printed. - Message string `json:"message"` - } - - // Batch encompasses a batch ID and the logs for this batch. - Batch struct { - ID string `json:"batch_id"` - Logs []Log `json:"logs"` - } -) - -// NewTailCommand returns a usable command registered under the parent. -func NewTailCommand(parent common.Registerer, globals *config.Data) *TailCommand { - var c TailCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("tail", "Tail Compute@Edge logs") - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("from", "From time, in unix seconds").Int64Var(&c.cfg.from) - c.CmdClause.Flag("to", "To time, in unix seconds").Int64Var(&c.cfg.to) - c.CmdClause.Flag("sort-buffer", - "Sort buffer is how long to buffer logs, attempting to sort them before printing, defaults to 1s (second)").Default("1s").DurationVar(&c.cfg.sortBuffer) - c.CmdClause.Flag("search-padding", - "Search padding is how much of a window on either side of From and To to use for searching, defaults to 2s (seconds)").Default("2s").DurationVar(&c.cfg.searchPadding) - c.CmdClause.Flag("stream", "Stream specifies which of 'stdout' or 'stderr' to output, defaults to undefined (all streams)").StringVar(&c.cfg.stream) - - return &c -} - -// Exec invokes the application logic for the command. -func (c *TailCommand) Exec(in io.Reader, out io.Writer) error { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - c.Input.ServiceID = serviceID - - c.Input.Kind = fastly.ManagedLoggingInstanceOutput - c.cfg.path = fmt.Sprintf("%s/service/%s/log_stream/managed/instance_output", config.DefaultEndpoint, c.Input.ServiceID) - - c.dieCh = make(chan struct{}) - c.batchCh = make(chan Batch) - c.doneCh = make(chan struct{}) - - c.hClient = http.DefaultClient - c.token, _ = c.Globals.Token() - - // Adjust the from/to times if they are - // defined. We adjust the times based on searchPadding. - c.adjustTimes() - - // Enable managed logging if not already enabled. - if err := c.enableManagedLogging(out); err != nil { - return err - } - - sigs := make(chan os.Signal, 2) - signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP) - - // Start the output loop. - go c.outputLoop(out) - - // Start tailing the logs. - go c.tail(out) - - <-sigs - close(c.dieCh) - - return nil -} - -// -// Client -// - -// Tail starts the virtual tail process. Tail fetches data from the eventbuffer -// API. It hands off the requested logs to the outputloop for the actual -// printing. -func (c *TailCommand) tail(out io.Writer) { - // Start this with --from and --to if set. - curWindow := c.cfg.from - toWindow := c.cfg.to - - // Start the loop with an initial address to query. - path := makeNewPath(out, c.cfg.path, curWindow, "") - - // lastBatchID keeps the last successfully read Batch.ID in case we need - // re-request on failure. - var lastBatchID string - - for { - // Check to see if we already passed the "to" requirement. - if toWindow != 0 && curWindow > toWindow { - text.Info(out, "Reached window: %v which is newer than the requested 'to': %v", curWindow, toWindow) - // We are done, but we still want printing to finish. - close(c.doneCh) - break - } - - req, err := http.NewRequest("GET", path, nil) - if err != nil { - text.Error(out, "unable to create new request: %v", err) - os.Exit(1) - } - req.Header.Add("Fastly-Key", c.token) - - resp, err := c.doReq(req) - if err != nil { - text.Error(out, "unable to execute request: %v", err) - os.Exit(1) - } - - // Check that our request was successful. If the server is - // having trouble, retry after waiting for some time. - if resp.StatusCode != http.StatusOK { - // If the response was a 404, the from time was - // not valid, give them an error stating this and exit. - if resp.StatusCode == http.StatusNotFound && - c.cfg.from != 0 { - text.Error(out, "specified 'from' time %d not found, either too far in the past or future", c.cfg.from) - os.Exit(1) - } - - // In an effort to clean up the output, do not print on - // 503's. - if resp.StatusCode != http.StatusServiceUnavailable { - text.Warning(out, "non-200 resp %d", resp.StatusCode) - } - - // Reuse the connection for the retry, or cleanup in the - // case of Exit. - io.Copy(io.Discard, resp.Body) - resp.Body.Close() - - // Try the response again after a 1 second wait. - if resp.StatusCode/100 == 5 && resp.StatusCode != 501 || - resp.StatusCode == 429 { - time.Sleep(1 * time.Second) - continue - } - - // Failing at this point is unrecoverable. - text.Error(out, "unrecoverable error, response code: %d", resp.StatusCode) - os.Exit(1) - } - - // Read and parse response, send batches to the output loop. - scanner := bufio.NewScanner(resp.Body) - - // Use a 10MB buffer for the bufio scanner, as we don't know - // how big some of the responses will be. - const tmb = 10 << 20 - buf := make([]byte, tmb) - scanner.Buffer(buf, tmb) - - for scanner.Scan() { - // Scan one line at a time, and get only one batch - // at a time. - b := scanner.Bytes() - batch, err := parseResponseData(b) - if err != nil { - // We can't parse the response, attempt to - // re-request from the last window & batch. - text.Warning(out, "unable to parse response body: %v", err) - path = makeNewPath(out, path, curWindow, lastBatchID) - continue - } - - // If we got a batch back, there will be an ID. - if batch.ID != "" { - // Record last batchID in case - // anything fails along the way, we - // can re-request. - lastBatchID = batch.ID - // Send batch down batchCh to the output loop. - c.batchCh <- batch - } - - } - resp.Body.Close() - - if err := scanner.Err(); err != nil { - // ErrUnexpectedEOFs need to be retried, but they - // produce a lot of noise for the user, so don't log. - if err != io.ErrUnexpectedEOF { - text.Warning(out, "error scanning response body: %v", err) - } - - // Something happened in the scanner, re-request the - // current batchID. - path = makeNewPath(out, path, curWindow, lastBatchID) - continue - } - - // Get our next time window to request. - _, next := getLinks(resp.Header) - curWindow, err = getTimeFromLink(next) - if err != nil { - text.Error(out, "error generating window from next link") - } - - // We do NOT want to specify a batchID, as this - // request was successful. - lastBatchID = "" - path = makeNewPath(out, path, curWindow, lastBatchID) - } -} - -// adjustTimes adjusts the passed in from and to flags based on the -// specified padding. -func (c *TailCommand) adjustTimes() { - if c.cfg.from != 0 { - // Adjust from based on search padding, we want to - // look back further. - c.cfg.from = c.cfg.from - int64(c.cfg.searchPadding.Seconds()) - } - - if c.cfg.to != 0 { - // Adjust to based on search padding, we want look forward more. - c.cfg.to = c.cfg.to + int64(c.cfg.searchPadding.Seconds()) - } -} - -// enableManagedLogging enables managed logging in our API. -func (c *TailCommand) enableManagedLogging(out io.Writer) error { - _, err := c.Globals.Client.CreateManagedLogging(&c.Input) - if err != nil && err != fastly.ErrManagedLoggingEnabled { - return err - } - - text.Info(out, "Managed logging enabled on service %s", c.Input.ServiceID) - return nil -} - -// outputLoop processes the logs out of band from the request/response loop. -func (c *TailCommand) outputLoop(out io.Writer) { - type ( - bufferedLog struct { - reqID string - seq int - } - - receive struct { - when time.Time - highSeq int - } - - logrecv struct { - logs []Log - receives []receive - } - ) - - // Channel for timers to notify they are done buffering. - tdCh := make(chan bufferedLog) - - // Single map to keep all buffered logs by RequestID as - // well recording when logs were received. - logmap := make(map[string]logrecv) - - for { - select { - case <-c.dieCh: - return - case batch := <-c.batchCh: // Got new batch. - // Range through batch logs, for each - // RequestID we create a timer based on the - // highest SequenceNum we got in this batch - // for that RequestID. If a timer already - // exists for the RequestID, we append the new - // time.Now() and high SequenceNum. At most - // there should be one timer per RequestID. - for reqid, logs := range splitByReqID(batch.Logs) { - // Required for use in AfterFunc below. - req := reqid - - // Record highest SequenceNum in this new batch - // for this RequestID - highSeq := highSequence(logs) - - // Whether we have the RequestID or not, we - // append and sort the logs slice. - reqLogs := logmap[req] - reqLogs.logs = append(reqLogs.logs, logs...) - // Sort the current batch of logs by their sequence number. - sort.Slice(reqLogs.logs, - func(i, j int) bool { - return reqLogs.logs[i].SequenceNum < reqLogs.logs[j].SequenceNum - }) - - // Check to see if we already have a timer - // running or if the current high sequence is - // higher than the one with the timer. - // The timer will always be running on the head - // of the slice. - recv := reqLogs.receives - - // In either case append to the receives slice. - if len(recv) == 0 || recv[0].highSeq < highSeq { - reqLogs.receives = append(recv, receive{ - when: time.Now(), - highSeq: highSeq, - }) - } - - // In only the empty case, start a new timer - // since this is the head of the slice. - if len(recv) == 0 { - time.AfterFunc(c.cfg.sortBuffer, func() { - tdCh <- bufferedLog{ - reqID: req, - seq: highSeq, - } - }) - } - - // Set the new log and receive info back to the - // logmap for this RequestID. - logmap[req] = reqLogs - } - - case bufdLogs := <-tdCh: // A timer expired for a particular request. - reqID, seq := bufdLogs.reqID, bufdLogs.seq - - // Get the logs for this RequestID and - // find the index of the sequence in our current logs. - reqLogs := logmap[reqID] - idx := findIdxBySeq(reqLogs.logs, seq) - - // Split off the source of this timer, leave - // remaining logs to be printed later. - toPrint, remainingLogs := reqLogs.logs[:idx], reqLogs.logs[idx:] - reqLogs.logs = remainingLogs - c.printLogs(out, toPrint) - - // Special case if we just printed the entire set of - // logs, we remove the keys from the maps and finish. - if len(remainingLogs) == 0 { - delete(logmap, reqID) - break - } - - // Drop the front of the batchReqReceives map and start - // another timer for any remaining recorded sequences. - recv := reqLogs.receives[1:] - reqLogs.receives = recv - - // If anything is left... - if len(recv) > 0 { - // We create a new timer, we subtract - // off time already served from the - // user defined sortBuffer. - time.AfterFunc(c.cfg.sortBuffer-time.Since(recv[0].when), func() { - tdCh <- bufferedLog{ - reqID: reqID, - seq: recv[0].highSeq, - } - }) - } - - // Set the new log and receive info back to the - // logmap for this RequestID. - logmap[reqID] = reqLogs - - case <-c.doneCh: - os.Exit(0) - } - } -} - -// printLogs is a simple printer for Log slices, only printing requested -// streams. -func (c *TailCommand) printLogs(out io.Writer, logs []Log) { - if len(logs) > 0 { - filtered := filterStream(c.cfg.stream, logs) - - for _, l := range filtered { - fmt.Fprintln(out, l.String()) - } - } -} - -// doReq runs the http.Request, returning a http.Response or error. -func (c *TailCommand) doReq(req *http.Request) (*http.Response, error) { - ctx, cancel := context.WithCancel(context.Background()) - req = req.WithContext(ctx) - go func() { - select { - case <-ctx.Done(): - case <-c.dieCh: - cancel() - } - }() - - resp, err := c.hClient.Do(req) - return resp, err -} - -// -// Log -// - -// RequestStartFromRaw return a time.Time object representing the -// RequestStart data. -func (l *Log) RequestStartFromRaw() time.Time { - // RequestTime comes as unix time in microseconds. Convert to - // nanoseconds, then parse with stdlib. - nano := l.RequestStart * 1000 - return time.Unix(0, nano) -} - -// String is used to print a log for the tail output. -func (l *Log) String() string { - // Trim the RequestID for nicer output, it might be a long UUID. - return fmt.Sprintf("%6s | %8.8s | %s", - l.Stream, - l.RequestID, - l.Message) -} - -// -// Helpers -// - -// makeNewPath generates a new request path based on current -// path, window, and batchID. -func makeNewPath(out io.Writer, path string, window int64, batchID string) string { - basePath, err := url.Parse(path) - if err != nil { - // No reasonable way to carry on from an error at this point - // and it should never happen, so error & exit. - text.Error(out, "error generating request URL: %v", err) - os.Exit(1) - } - - // Unset anything in the query parameters that might already exist. - basePath.RawQuery = "" - - q := basePath.Query() - if window != 0 { - q.Set("from", strconv.FormatInt(window, 10)) - } - - if batchID != "" { - q.Set("batch_id", batchID) - } - - basePath.RawQuery = q.Encode() - return basePath.String() -} - -// splitByReqID splits slices of logs based on RequestID, -func splitByReqID(in []Log) map[string][]Log { - out := make(map[string][]Log) - for _, l := range in { - out[l.RequestID] = append(out[l.RequestID], l) - } - return out -} - -// parseResponseData returns the batch from a response. -func parseResponseData(data []byte) (Batch, error) { - var batch Batch - reader := bytes.NewReader(data) - d := json.NewDecoder(reader) - - if err := d.Decode(&batch); err != nil && err != io.EOF { - return batch, err - } - - return batch, nil -} - -// filterStream returns only logs that are requested by the stream flag. -func filterStream(stream string, logs []Log) []Log { - // If unset, do not filter out any logs. - if stream == "" { - return logs - } - - var out []Log - for _, l := range logs { - // If the stream matches what they wanted, keep it. - if stream == l.Stream { - out = append(out, l) - } - } - return out -} - -// getTimeFromLink splits a link header format, returning -// the time. -func getTimeFromLink(link string) (int64, error) { - s := strings.SplitN(link, "=", 2)[1] - return strconv.ParseInt(s, 10, 64) -} - -// getLinks returns the prev and next links from a header. -func getLinks(head http.Header) (prev, next string) { - links := linkheader.ParseMultiple(head["Link"]) - for _, link := range links { - switch link.Rel { - case "prev": - prev = link.URL - case "next": - next = link.URL - } - } - return -} - -// findIdxBySeq returns the slice index after the -// SequenceNum we are searching for. -func findIdxBySeq(logs []Log, seq int) int { - for i, v := range logs { - if v.SequenceNum > seq { - return i - } - } - return len(logs) -} - -// highSequence returns the highest SequenceNum -// in a slice of logs. -func highSequence(logs []Log) int { - var max int - for _, l := range logs { - if l.SequenceNum > max { - max = l.SequenceNum - } - } - return max -} diff --git a/pkg/lookup/doc.go b/pkg/lookup/doc.go new file mode 100644 index 000000000..9ba8f485f --- /dev/null +++ b/pkg/lookup/doc.go @@ -0,0 +1,2 @@ +// Package lookup defines an enum that identifies a parameter's source. +package lookup diff --git a/pkg/lookup/lookup.go b/pkg/lookup/lookup.go new file mode 100644 index 000000000..23995e6cb --- /dev/null +++ b/pkg/lookup/lookup.go @@ -0,0 +1,22 @@ +package lookup + +// Source enumerates where the parameter is taken from. +type Source uint8 + +const ( + // SourceUndefined indicates the parameter isn't provided in any of the + // available sources, similar to "not found". + SourceUndefined Source = iota + + // SourceFile indicates the parameter came from a config file. + SourceFile + + // SourceEnvironment indicates the parameter came from an env var. + SourceEnvironment + + // SourceFlag indicates the parameter came from an explicit flag. + SourceFlag + + // SourceDefault indicates the parameter came from a program default. + SourceDefault +) diff --git a/pkg/manifest/data.go b/pkg/manifest/data.go new file mode 100644 index 000000000..8607eeb43 --- /dev/null +++ b/pkg/manifest/data.go @@ -0,0 +1,67 @@ +package manifest + +import ( + "os" + + "github.com/fastly/cli/pkg/env" +) + +// Data holds global-ish manifest data from manifest files, and flag sources. +// It has methods to give each parameter to the components that need it, +// including the place the parameter came from, which is a requirement. +// +// If the same parameter is defined in multiple places, it is resolved according +// to the following priority order: the manifest file (lowest priority) and then +// environment variables (where applicable), and explicit flags (highest priority). +type Data struct { + File File + Flag Flag +} + +// Authors yields an Authors. +func (d *Data) Authors() ([]string, Source) { + if len(d.Flag.Authors) > 0 { + return d.Flag.Authors, SourceFlag + } + + if len(d.File.Authors) > 0 { + return d.File.Authors, SourceFile + } + + return []string{}, SourceUndefined +} + +// Description yields a Description. +func (d *Data) Description() (string, Source) { + if d.File.Description != "" { + return d.File.Description, SourceFile + } + + return "", SourceUndefined +} + +// Name yields a Name. +func (d *Data) Name() (string, Source) { + if d.File.Name != "" { + return d.File.Name, SourceFile + } + + return "", SourceUndefined +} + +// ServiceID yields a ServiceID. +func (d *Data) ServiceID() (string, Source) { + if d.Flag.ServiceID != "" { + return d.Flag.ServiceID, SourceFlag + } + + if sid := os.Getenv(env.ServiceID); sid != "" { + return sid, SourceEnv + } + + if d.File.ServiceID != "" { + return d.File.ServiceID, SourceFile + } + + return "", SourceUndefined +} diff --git a/pkg/manifest/doc.go b/pkg/manifest/doc.go new file mode 100644 index 000000000..a1a4cbaae --- /dev/null +++ b/pkg/manifest/doc.go @@ -0,0 +1,3 @@ +// Package manifest contains functions and objects for managing interactions +// with the Fastly manifest file. +package manifest diff --git a/pkg/manifest/file.go b/pkg/manifest/file.go new file mode 100644 index 000000000..c937eb36b --- /dev/null +++ b/pkg/manifest/file.go @@ -0,0 +1,363 @@ +package manifest + +import ( + "bufio" + "bytes" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + toml "github.com/pelletier/go-toml" + + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/text" +) + +// File represents all of the configuration parameters in the fastly.toml +// manifest file schema. +type File struct { + // Args is necessary to track the subcommand called (see: File.Read method). + Args []string `toml:"-"` + // Authors is a list of project authors (typically an email). + Authors []string `toml:"authors"` + // ClonedFrom indicates the GitHub repo the starter kit was cloned from. + // This could be an empty value if the user doesn't use `compute init`. + ClonedFrom string `toml:"cloned_from,omitempty"` + // Description is the project description. + Description string `toml:"description"` + // Language is the programming language used for the project. + Language string `toml:"language"` + // Profile is the name of the profile account the Fastly CLI should use to make API requests. + Profile string `toml:"profile,omitempty"` + // LocalServer describes the configuration for the local server built into the Fastly CLI. + LocalServer LocalServer `toml:"local_server,omitempty"` + // ManifestVersion is the manifest schema version number. + ManifestVersion Version `toml:"manifest_version"` + // Name is the package name. + Name string `toml:"name"` + // Scripts describes customisation options for the Fastly CLI build step. + Scripts Scripts `toml:"scripts,omitempty"` + // ServiceID is the Fastly Service ID to deploy the package to. + ServiceID string `toml:"service_id"` + // Setup describes a set of service configuration that works with the code in the package. + Setup Setup `toml:"setup,omitempty"` + + quiet bool + errLog fsterr.LogInterface + exists bool + output io.Writer + readError error +} + +// MarshalTOML performs custom marshalling to TOML for objects of File type. +func (f *File) MarshalTOML() ([]byte, error) { + localServer := make(map[string]any) + + if f.LocalServer.Backends != nil { + localServer["backends"] = f.LocalServer.Backends + } + + if f.LocalServer.ConfigStores != nil { + localServer["config_stores"] = f.LocalServer.ConfigStores + } + + if f.LocalServer.KVStores != nil { + kvStores := make(map[string]any) + for key, entry := range f.LocalServer.KVStores { + if entry.External != nil { + kvStores[key] = map[string]any{ + "file": entry.External.File, + "format": entry.External.Format, + } + } else { + items := make([]map[string]any, 0, len(entry.Array)) + for _, e := range entry.Array { + obj := map[string]any{"key": e.Key} + if e.File != "" { + obj["file"] = e.File + } + if e.Data != "" { + obj["data"] = e.Data + } + if e.Metadata != "" { + obj["metadata"] = e.Metadata + } + items = append(items, obj) + } + kvStores[key] = items + } + } + localServer["kv_stores"] = kvStores + } + + if f.LocalServer.SecretStores != nil { + secretStores := make(map[string]any) + for key, entry := range f.LocalServer.SecretStores { + if entry.External != nil { + secretStores[key] = map[string]any{ + "file": entry.External.File, + "format": entry.External.Format, + } + } else { + items := make([]map[string]any, 0, len(entry.Array)) + for _, e := range entry.Array { + obj := map[string]any{"key": e.Key} + if e.File != "" { + obj["file"] = e.File + } + if e.Data != "" { + obj["data"] = e.Data + } + items = append(items, obj) + } + secretStores[key] = items + } + } + localServer["secret_stores"] = secretStores + } + + if f.LocalServer.ViceroyVersion != "" { + localServer["viceroy_version"] = f.LocalServer.ViceroyVersion + } + + out := struct { + Authors []string `toml:"authors"` + ClonedFrom string `toml:"cloned_from,omitempty"` + Description string `toml:"description"` + Language string `toml:"language"` + Profile string `toml:"profile,omitempty"` + LocalServer any `toml:"local_server"` // override this field + ManifestVersion Version `toml:"manifest_version"` + Name string `toml:"name"` + Scripts Scripts `toml:"scripts,omitempty"` + ServiceID string `toml:"service_id"` + Setup Setup `toml:"setup,omitempty"` + }{ + Authors: f.Authors, + ClonedFrom: f.ClonedFrom, + Description: f.Description, + Language: f.Language, + Profile: f.Profile, + LocalServer: localServer, + ManifestVersion: f.ManifestVersion, + Name: f.Name, + Scripts: f.Scripts, + ServiceID: f.ServiceID, + Setup: f.Setup, + } + + var buf bytes.Buffer + err := toml.NewEncoder(&buf).Encode(out) + return buf.Bytes(), err +} + +// Exists yields whether the manifest exists. +// +// Specifically, it indicates that a toml.Unmarshal() of the toml disk content +// to data in memory was successful without error. +func (f *File) Exists() bool { + return f.exists +} + +// Read loads the manifest file content from disk. +func (f *File) Read(path string) (err error) { + defer func() { + if err != nil { + f.readError = err + } + }() + + // gosec flagged this: + // G304 (CWE-22): Potential file inclusion via variable. + // Disabling as we need to load the fastly.toml from the user's file system. + // This file is decoded into a predefined struct, any unrecognised fields are dropped. + // #nosec + tree, err := toml.LoadFile(path) + if err != nil { + // IMPORTANT: Only `fastly compute` references the fastly.toml file. + if len(f.Args) > 0 && f.Args[0] == "compute" { + f.logErr(err) // only log error if user executed `compute` subcommand. + } + return err + } + + err = tree.Unmarshal(f) + if err != nil { + // IMPORTANT: go-toml consumes our error type within its own. + // + // This means we need to manually parse the return error to see if it + // contains our specific error message. If we don't do this, then the + // remediation information we pass back will be lost and a generic 'bug' + // remediation (which is set by logic in main.go) is used instead. + if strings.Contains(err.Error(), fsterr.ErrUnrecognisedManifestVersion.Inner.Error()) { + err = fsterr.ErrUnrecognisedManifestVersion + } + f.logErr(err) + return err + } + + if f.Scripts.EnvFile != "" { + if err := f.ParseEnvFile(); err != nil { + return err + } + } + + if f.ManifestVersion == 0 { + f.ManifestVersion = ManifestLatestVersion + + if !f.quiet { + text.Warning(f.output, fmt.Sprintf("The fastly.toml was missing a `manifest_version` field. A default schema version of `%d` will be used.\n\n", ManifestLatestVersion)) + text.Output(f.output, fmt.Sprintf("Refer to the fastly.toml package manifest format: %s\n\n", SpecURL)) + } + err = f.Write(path) + if err != nil { + f.logErr(err) + return fmt.Errorf("unable to save fastly.toml manifest change: %w", err) + } + } + + if dt := tree.Get("setup.dictionaries"); dt != nil { + text.Warning(f.output, "Your fastly.toml manifest contains `[setup.dictionaries]`, which should be updated to `[setup.config_stores]`. Refer to the documentation at https://www.fastly.com/documentation/reference/compute/fastly-toml\n\n") + } + + f.exists = true + return nil +} + +// ParseEnvFile reads the environment file `env_file` and appends all KEY=VALUE +// pairs to the existing `f.Scripts.EnvVars`. +func (f *File) ParseEnvFile() error { + // IMPORTANT: Avoid persisting potentially secret values to disk. + // We do this by keeping a copy of EnvVars before they're appended to. + // Inside of File.Write() we'll reassign EnvVars the original values. + manifestDefinedEnvVars := make([]string, len(f.Scripts.EnvVars)) + copy(manifestDefinedEnvVars, f.Scripts.EnvVars) + f.Scripts.manifestDefinedEnvVars = manifestDefinedEnvVars + + path, err := filepath.Abs(f.Scripts.EnvFile) + if err != nil { + return fmt.Errorf("failed to generate absolute path for '%s': %w", f.Scripts.EnvFile, err) + } + r, err := os.Open(path) // #nosec G304 (CWE-22) + if err != nil { + return fmt.Errorf("failed to open path '%s': %w", path, err) + } + scanner := bufio.NewScanner(r) + for scanner.Scan() { + parts := strings.Split(scanner.Text(), "=") + if len(parts) != 2 { + return fmt.Errorf("failed to scan env_file '%s': invalid KEY=VALUE format: %#v", path, parts) + } + parts[1] = strings.Trim(parts[1], `"'`) + f.Scripts.EnvVars = append(f.Scripts.EnvVars, strings.Join(parts, "=")) + } + if err := scanner.Err(); err != nil { + return fmt.Errorf("failed to scan env_file '%s': %w", path, err) + } + return nil +} + +// ReadError yields the error returned from Read(). +// +// NOTE: We no longer call Read() from every command. We only call it once +// within app.Run() but we don't handle any errors that are returned from the +// Read() method. This is because failing to read the manifest is fine if the +// error is caused by the file not existing in a directory where the user is +// working on a non-Compute project. This will enable code elsewhere in the CLI to +// understand why the Read() failed. For example, we can use errors.Is() to +// allow returning a specific remediation error from a Compute related command. +func (f *File) ReadError() error { + return f.readError +} + +// SetErrLog sets an instance of errors.LogInterface. +func (f *File) SetErrLog(errLog fsterr.LogInterface) { + f.errLog = errLog +} + +// SetOutput sets the output stream for any messages. +func (f *File) SetOutput(output io.Writer) { + f.output = output +} + +// SetQuiet sets the associated flag value. +func (f *File) SetQuiet(v bool) { + f.quiet = v +} + +// Write persists the manifest content to disk. +func (f *File) Write(path string) error { + fp, err := os.Create(path) // #nosec G304 (CWE-22) + if err != nil { + return err + } + if err := appendSpecRef(fp); err != nil { + return err + } + + // IMPORTANT: Avoid persisting potentially secret values to disk. + // We do this by keeping a copy of EnvVars before they're appended to. + // i.e. f.Scripts.manifestDefinedEnvVars + // We now reassign EnvVars the original values (pre-EnvFile modification). + // But we also need to account for the in-memory representation. + // + // i.e. we call File.Write() at different times but still need EnvVars data. + // + // So once we've persisted the correct data back to disk, we can then revert + // the in-memory data for EnvVars to include the contents from EnvFile + // i.e. combinedEnvVars + // just in case the CLI process is still running and needs to do things with + // environment variables. + if f.Scripts.EnvFile != "" { + combinedEnvVars := make([]string, len(f.Scripts.EnvVars)) + copy(combinedEnvVars, f.Scripts.EnvVars) + f.Scripts.EnvVars = f.Scripts.manifestDefinedEnvVars + defer func() { + f.Scripts.EnvVars = combinedEnvVars + }() + } + + if err := toml.NewEncoder(fp).Encode(f); err != nil { + return err + } + if err := fp.Sync(); err != nil { + return err + } + return fp.Close() +} + +func (f *File) logErr(err error) { + if f.errLog != nil { + f.errLog.Add(err) + } +} + +// appendSpecRef appends the fastly.toml specification URL to the manifest. +func appendSpecRef(w io.Writer) error { + s := fmt.Sprintf("# %s\n# %s\n\n", SpecIntro, SpecURL) + _, err := io.WriteString(w, s) + return err +} + +// Scripts represents build configuration. +type Scripts struct { + // Build is a custom build script. + Build string `toml:"build,omitempty"` + // EnvFile is a path to a file containing build related environment variables. + // Each line should contain a KEY=VALUE. + // Reading the contents of this file will populate the `EnvVars` field. + EnvFile string `toml:"env_file,omitempty"` + // EnvVars contains build related environment variables. + EnvVars []string `toml:"env_vars,omitempty"` + // PostBuild is executed after the build step. + PostBuild string `toml:"post_build,omitempty"` + // PostInit is executed after the init step. + PostInit string `toml:"post_init,omitempty"` + + // Private field used to revert modifications to EnvVars from EnvFile. + // See File.ParseEnvFile() and File.Write() methods for details. + // This will contain the environment variables defined in the manifest file. + manifestDefinedEnvVars []string +} diff --git a/pkg/manifest/flags.go b/pkg/manifest/flags.go new file mode 100644 index 000000000..174c5fc7d --- /dev/null +++ b/pkg/manifest/flags.go @@ -0,0 +1,10 @@ +package manifest + +// Flag represents all of the manifest parameters that can be set with explicit +// flags. Consumers should bind their flag values to these fields directly. +type Flag struct { + Name string + Description string + Authors []string + ServiceID string +} diff --git a/pkg/manifest/local_server.go b/pkg/manifest/local_server.go new file mode 100644 index 000000000..5a1d87161 --- /dev/null +++ b/pkg/manifest/local_server.go @@ -0,0 +1,202 @@ +package manifest + +import ( + "bytes" + "fmt" + + "github.com/pelletier/go-toml" +) + +// LocalServer represents a list of mocked Viceroy resources. +type LocalServer struct { + Backends map[string]LocalBackend `toml:"backends"` + ConfigStores map[string]LocalConfigStore `toml:"config_stores,omitempty"` + KVStores LocalKVStoreMap `toml:"kv_stores,omitempty"` + SecretStores LocalSecretStoreMap `toml:"secret_stores,omitempty"` + ViceroyVersion string `toml:"viceroy_version,omitempty"` +} + +// LocalBackend represents a backend to be mocked by the local testing server. +type LocalBackend struct { + URL string `toml:"url"` + OverrideHost string `toml:"override_host,omitempty"` + CertHost string `toml:"cert_host,omitempty"` + UseSNI bool `toml:"use_sni,omitempty"` +} + +// LocalConfigStore represents a config store to be mocked by the local testing server. +type LocalConfigStore struct { + File string `toml:"file,omitempty"` + Format string `toml:"format"` + Contents map[string]string `toml:"contents,omitempty"` +} + +// KVStoreArrayEntry represents an array-based key/value store entries. +// It expects a key plus either a data or file field. +type KVStoreArrayEntry struct { + Key string `toml:"key"` + File string `toml:"file,omitempty"` + Data string `toml:"data,omitempty"` + Metadata string `toml:"metadata,omitempty"` +} + +// KVStoreExternalFile represents the external key/value store, +// which must have both a file and a format. +type KVStoreExternalFile struct { + File string `toml:"file"` + Format string `toml:"format"` +} + +// LocalKVStore represents a kv_store to be mocked by the local testing server. +// It is a union type and can either be an array of KVStoreArrayEntry or a single KVStoreExternalFile. +// The IsArray flag is used to preserve the original input style. +type LocalKVStore struct { + IsArray bool `toml:"-"` + Array []KVStoreArrayEntry `toml:"-"` + External *KVStoreExternalFile `toml:"-"` +} + +// LocalKVStoreMap is a map of kv_store names to the local kv_store representation. +type LocalKVStoreMap map[string]LocalKVStore + +// UnmarshalTOML performs custom unmarshalling of TOML data for LocalKVStoreMap. +func (m *LocalKVStoreMap) UnmarshalTOML(v any) error { + raw, ok := v.(map[string]any) + if !ok { + return fmt.Errorf("expected kv_stores to be a TOML table") + } + + result := make(LocalKVStoreMap) + + for key, val := range raw { + switch typed := val.(type) { + case []any: + var entries []KVStoreArrayEntry + for _, item := range typed { + obj, ok := item.(map[string]any) + if !ok { + return fmt.Errorf("invalid item in array for key %q", key) + } + var arrayEntry KVStoreArrayEntry + if err := decodeTOMLMap(obj, &arrayEntry); err != nil { + return fmt.Errorf("decode failed for array item in key %q: %w", key, err) + } + entries = append(entries, arrayEntry) + } + result[key] = LocalKVStore{ + IsArray: true, + Array: entries, + } + + case map[string]any: + file, hasFile := typed["file"].(string) + format, hasFormat := typed["format"].(string) + + if !hasFile || !hasFormat { + return fmt.Errorf("key %q must have both file and format", key) + } + result[key] = LocalKVStore{ + IsArray: false, + External: &KVStoreExternalFile{ + File: file, + Format: format, + }, + } + + default: + return fmt.Errorf("unsupported value type for key %q: %T", key, typed) + } + } + + *m = result + return nil +} + +// SecretStoreArrayEntry represents an array-based key/value store entries. +// It expects a key plus either a data or file field. +type SecretStoreArrayEntry struct { + Key string `toml:"key"` + File string `toml:"file,omitempty"` + Data string `toml:"data,omitempty"` +} + +// SecretStoreExternalFile represents the external key/value store, +// which must have both a file and a format. +type SecretStoreExternalFile struct { + File string `toml:"file"` + Format string `toml:"format"` +} + +// LocalSecretStore represents a secret_store to be mocked by the local testing server. +// It is a union type and can either be an array of SecretStoreArrayEntry or a single SecretStoreExternalFile. +// The IsArray flag is used to preserve the original input style. +type LocalSecretStore struct { + IsArray bool `toml:"-"` + Array []SecretStoreArrayEntry `toml:"-"` + External *SecretStoreExternalFile `toml:"-"` +} + +// LocalSecretStoreMap is a map of secret_store names to the local secret_store representation. +type LocalSecretStoreMap map[string]LocalSecretStore + +// UnmarshalTOML performs custom unmarshalling of TOML data for LocalSecretStoreMap. +func (m *LocalSecretStoreMap) UnmarshalTOML(v any) error { + raw, ok := v.(map[string]any) + if !ok { + return fmt.Errorf("expected secret_stores to be a TOML table") + } + + result := make(LocalSecretStoreMap) + + for key, val := range raw { + switch typed := val.(type) { + case []any: + var entries []SecretStoreArrayEntry + for _, item := range typed { + obj, ok := item.(map[string]any) + if !ok { + return fmt.Errorf("invalid item in array for key %q", key) + } + var arrayEntry SecretStoreArrayEntry + if err := decodeTOMLMap(obj, &arrayEntry); err != nil { + return fmt.Errorf("decode failed for array item in key %q: %w", key, err) + } + entries = append(entries, arrayEntry) + } + result[key] = LocalSecretStore{ + IsArray: true, + Array: entries, + } + + case map[string]any: + file, hasFile := typed["file"].(string) + format, hasFormat := typed["format"].(string) + + if !hasFile || !hasFormat { + return fmt.Errorf("key %q must have both file and format", key) + } + result[key] = LocalSecretStore{ + IsArray: false, + External: &SecretStoreExternalFile{ + File: file, + Format: format, + }, + } + + default: + return fmt.Errorf("unsupported value type for key %q: %T", key, typed) + } + } + + *m = result + return nil +} + +func decodeTOMLMap(m map[string]any, out any) error { + buf := new(bytes.Buffer) + enc := toml.NewEncoder(buf) + if err := enc.Encode(m); err != nil { + return err + } + return toml.NewDecoder(buf).Decode(out) +} diff --git a/pkg/manifest/local_server_test.go b/pkg/manifest/local_server_test.go new file mode 100644 index 000000000..2e6371b0e --- /dev/null +++ b/pkg/manifest/local_server_test.go @@ -0,0 +1,167 @@ +package manifest + +import ( + "reflect" + "strings" + "testing" + + "github.com/pelletier/go-toml" +) + +func TestLocalKVStores_UnmarshalTOML(t *testing.T) { + tests := []struct { + name string + inputTOML string + expectError bool + expected LocalKVStore + }{ + { + name: "legacy array format", + inputTOML: ` +[[kv_stores.my-kv]] +key = "kv" +file = "kv.json" +metadata = "metadata" +`, + expected: LocalKVStore{ + IsArray: true, + Array: []KVStoreArrayEntry{ + { + Key: "kv", + File: "kv.json", + Metadata: "metadata", + }, + }, + }, + }, + { + name: "external file format", + inputTOML: ` +[kv_stores] +my-kv = { file = "kv.json", format = "json" } +`, + expected: LocalKVStore{ + IsArray: false, + External: &KVStoreExternalFile{ + File: "kv.json", + Format: "json", + }, + }, + }, + { + name: "invalid format", + inputTOML: ` +[kv_stores] +my-kv = "not-a-valid-entry" +`, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var m struct { + KVStores LocalKVStoreMap `toml:"kv_stores"` + } + + decoder := toml.NewDecoder(strings.NewReader(tt.inputTOML)) + err := decoder.Decode(&m) + + if tt.expectError { + if err == nil { + t.Fatal("Expected error for invalid format, but got none") + } + return + } else if err != nil { + t.Fatalf("Failed to parse TOML: %v", err) + } + + got, ok := m.KVStores["my-kv"] + if !ok { + t.Fatalf("Expected key 'my-kv' not found") + } + + if !reflect.DeepEqual(got, tt.expected) { + t.Errorf("Mismatch!\nGot: %+v\nWant: %+v", got, tt.expected) + } + }) + } +} + +func TestLocalSecretStores_UnmarshalTOML(t *testing.T) { + tests := []struct { + name string + inputTOML string + expectError bool + expected LocalSecretStore + }{ + { + name: "legacy array format", + inputTOML: ` +[[secret_stores.my-secret-store]] +key = "secret" +file = "secret.json" +`, + expected: LocalSecretStore{ + IsArray: true, + Array: []SecretStoreArrayEntry{ + { + Key: "secret", + File: "secret.json", + }, + }, + }, + }, + { + name: "external file format", + inputTOML: ` +[secret_stores] +my-secret-store = { file = "secret.json", format = "json" } +`, + expected: LocalSecretStore{ + IsArray: false, + External: &SecretStoreExternalFile{ + File: "secret.json", + Format: "json", + }, + }, + }, + { + name: "invalid format", + inputTOML: ` +[secret_stores] +my-secret-store = "not-a-valid-entry" +`, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var m struct { + SecretStores LocalSecretStoreMap `toml:"secret_stores"` + } + + decoder := toml.NewDecoder(strings.NewReader(tt.inputTOML)) + err := decoder.Decode(&m) + + if tt.expectError { + if err == nil { + t.Fatal("Expected error for invalid format, but got none") + } + return + } else if err != nil { + t.Fatalf("Failed to parse TOML: %v", err) + } + + got, ok := m.SecretStores["my-secret-store"] + if !ok { + t.Fatalf("Expected key 'my-secret-store' not found") + } + + if !reflect.DeepEqual(got, tt.expected) { + t.Errorf("Mismatch!\nGot: %+v\nWant: %+v", got, tt.expected) + } + }) + } +} diff --git a/pkg/manifest/manifest.go b/pkg/manifest/manifest.go new file mode 100644 index 000000000..5c3429018 --- /dev/null +++ b/pkg/manifest/manifest.go @@ -0,0 +1,39 @@ +package manifest + +// Source enumerates where a manifest parameter is taken from. +type Source uint8 + +const ( + // Filename is the name of the package manifest file. + // It is expected to be a project specific configuration file. + Filename = "fastly.toml" + + // ManifestLatestVersion represents the latest known manifest schema version + // supported by the CLI. + // + // NOTE: The CLI is the primary consumer of the fastly.toml manifest so its + // code is typically coupled to the specification. + ManifestLatestVersion = 3 + + // FilePermissions represents a read/write file mode. + FilePermissions = 0o666 + + // SourceUndefined indicates the parameter isn't provided in any of the + // available sources, similar to "not found". + SourceUndefined Source = iota + + // SourceFile indicates the parameter came from a manifest file. + SourceFile + + // SourceEnv indicates the parameter came from the user's shell environment. + SourceEnv + + // SourceFlag indicates the parameter came from an explicit flag. + SourceFlag + + // SpecIntro informs the user of what the manifest file is for. + SpecIntro = "This file describes a Fastly Compute package. To learn more visit:" + + // SpecURL points to the fastly.toml manifest specification reference. + SpecURL = "https://www.fastly.com/documentation/reference/compute/fastly-toml" +) diff --git a/pkg/manifest/manifest_test.go b/pkg/manifest/manifest_test.go new file mode 100644 index 000000000..09f775e15 --- /dev/null +++ b/pkg/manifest/manifest_test.go @@ -0,0 +1,310 @@ +package manifest_test + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + toml "github.com/pelletier/go-toml" + + "github.com/fastly/cli/pkg/env" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/testutil" + "github.com/fastly/cli/pkg/threadsafe" +) + +func TestManifest(t *testing.T) { + tests := map[string]struct { + manifest string + valid bool + expectedError error + wantRemediationError string + expectedOutput string + }{ + "valid: semver": { + manifest: "fastly-valid-semver.toml", + valid: true, + }, + "valid: integer": { + manifest: "fastly-valid-integer.toml", + valid: true, + }, + "invalid: missing manifest_version": { + manifest: "fastly-invalid-missing-version.toml", + valid: true, // expect manifest_version to be set to latest version + }, + "invalid: manifest_version Atoi error": { + manifest: "fastly-invalid-unrecognised.toml", + valid: false, + expectedError: fmt.Errorf("error parsing manifest_version 'abc'"), + }, + "unrecognised: manifest_version exceeded limit": { + manifest: "fastly-invalid-version-exceeded.toml", + valid: false, + expectedError: fsterr.ErrUnrecognisedManifestVersion, + }, + "warning: dictionaries now replaced with config_stores": { + manifest: "fastly-warning-dictionaries.toml", + valid: true, // we display a warning but we don't exit command execution + expectedOutput: "WARNING: Your fastly.toml manifest contains `[setup.dictionaries]`", + }, + } + + // NOTE: some of the fixture files are overwritten by the application logic + // and so to ensure future test runs can complete successfully we do an + // initial read of the data and then write it back to disk once the tests + // have completed. + + prefix := filepath.Join("./", "testdata") + + for _, fpath := range []string{ + "fastly-valid-semver.toml", + "fastly-valid-integer.toml", + "fastly-invalid-missing-version.toml", + "fastly-invalid-unrecognised.toml", + "fastly-invalid-version-exceeded.toml", + } { + path, err := filepath.Abs(filepath.Join(prefix, fpath)) + if err != nil { + t.Fatal(err) + } + + b, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + + defer func(path string, b []byte) { + err := os.WriteFile(path, b, 0o600) + if err != nil { + t.Fatal(err) + } + }(path, b) + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + var ( + m manifest.File + stdout threadsafe.Buffer + ) + m.SetErrLog(fsterr.Log) + m.SetOutput(&stdout) + + path, err := filepath.Abs(filepath.Join(prefix, tc.manifest)) + if err != nil { + t.Fatal(err) + } + + err = m.Read(path) + + output := stdout.String() + t.Log(output) + + // If we expect an invalid config, then assert we get the right error. + if !tc.valid { + testutil.AssertErrorContains(t, err, tc.expectedError.Error()) + return + } + + // Otherwise, if we expect the manifest to be valid and we get an error, + // then that's unexpected behaviour. + if err != nil { + t.Fatal(err) + } + + if m.ManifestVersion != manifest.ManifestLatestVersion { + t.Fatalf("manifest_version '%d' doesn't match latest '%d'", m.ManifestVersion, manifest.ManifestLatestVersion) + } + + if tc.expectedOutput != "" && !strings.Contains(output, tc.expectedOutput) { + t.Fatalf("got: %s, want: %s", output, tc.expectedOutput) + } + }) + } +} + +func TestManifestPrepend(t *testing.T) { + var ( + manifestBody []byte + manifestPath string + ) + + // NOTE: the fixture file "fastly-missing-spec-url.toml" will be + // overwritten by the test as the internal logic is supposed to add into the + // manifest a reference to the fastly.toml specification. + // + // To ensure future test runs complete successfully we do an initial read of + // the data and then write it back out when the tests have completed. + { + path, err := filepath.Abs(filepath.Join("./", "testdata", "fastly-missing-spec-url.toml")) + if err != nil { + t.Fatal(err) + } + + manifestBody, err = os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + + defer func(path string, b []byte) { + err := os.WriteFile(path, b, 0o600) + if err != nil { + t.Fatal(err) + } + }(path, manifestBody) + } + + // Create temp environment to run test code within. + { + wd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + + rootdir := testutil.NewEnv(testutil.EnvOpts{ + T: t, + Write: []testutil.FileIO{ + {Src: string(manifestBody), Dst: "fastly.toml"}, + }, + }) + manifestPath = filepath.Join(rootdir, "fastly.toml") + defer os.RemoveAll(rootdir) + + if err := os.Chdir(rootdir); err != nil { + t.Fatal(err) + } + defer func() { + _ = os.Chdir(wd) + }() + } + + var f manifest.File + err := f.Read(manifestPath) + if err != nil { + t.Fatal(err) + } + + err = f.Write(manifestPath) + if err != nil { + t.Fatal(err) + } + + updatedManifest, err := os.ReadFile(manifestPath) + if err != nil { + t.Fatal(err) + } + content := string(updatedManifest) + + if !strings.Contains(content, manifest.SpecIntro) || !strings.Contains(content, manifest.SpecURL) { + t.Fatal("missing fastly.toml specification reference link") + } +} + +func TestDataServiceID(t *testing.T) { + t.Setenv(env.ServiceID, "001") + + // SourceFlag + d := manifest.Data{ + Flag: manifest.Flag{ServiceID: "123"}, + File: manifest.File{ServiceID: "456"}, + } + _, src := d.ServiceID() + if src != manifest.SourceFlag { + t.Fatal("expected SourceFlag") + } + + // SourceEnv + d.Flag = manifest.Flag{} + _, src = d.ServiceID() + if src != manifest.SourceEnv { + t.Fatal("expected SourceEnv") + } + + // SourceFile + t.Setenv(env.ServiceID, "") + _, src = d.ServiceID() + if src != manifest.SourceFile { + t.Fatal("expected SourceFile") + } +} + +// This test validates that manually added changes, such as the toml +// syntax for Viceroy local testing, are not accidentally deleted after +// decoding and encoding flows. +func TestManifestPersistsLocalServerSection(t *testing.T) { + fpath := filepath.Join("./", "testdata", "fastly-viceroy-update.toml") + + b, err := os.ReadFile(fpath) + if err != nil { + t.Fatal(err) + } + + defer func(fpath string, b []byte) { + err := os.WriteFile(fpath, b, 0o600) + if err != nil { + t.Fatal(err) + } + }(fpath, b) + + original, err := toml.LoadFile(fpath) + if err != nil { + t.Fatal(err) + } + + ot := original.Get("local_server") + if ot == nil { + t.Fatal("expected [local_server] block to exist in fastly.toml but is missing") + } + + osid := original.Get("service_id") + if osid != nil { + t.Fatal("did not expect service_id key to exist in fastly.toml but is present") + } + + var m manifest.File + + err = m.Read(fpath) + if err != nil { + t.Fatal(err) + } + + m.ServiceID = "a change occurred to the data structure" + + err = m.Write(fpath) + if err != nil { + t.Fatal(err) + } + + latest, err := toml.LoadFile(fpath) + if err != nil { + t.Fatal(err) + } + + lsid := latest.Get("service_id") + if lsid == nil { + t.Fatal("expected service_id key to exist in fastly.toml but is missing") + } + + lt := latest.Get("local_server") + if lt == nil { + t.Fatal("expected [local_server] block to exist in fastly.toml but is missing") + } + + localTree, ok := lt.(*toml.Tree) + if !ok { + t.Fatal("failed to convert 'local' interface{} to toml.Tree") + } + originalTree, ok := ot.(*toml.Tree) + if !ok { + t.Fatal("failed to convert 'original' interface{} to toml.Tree") + } + want, got := originalTree.String(), localTree.String() + if diff := cmp.Diff(want, got); diff != "" { + t.Fatalf("testing section between original and updated fastly.toml do not match (-want +got):\n%s", diff) + } +} diff --git a/pkg/manifest/setup.go b/pkg/manifest/setup.go new file mode 100644 index 000000000..97e8eca4d --- /dev/null +++ b/pkg/manifest/setup.go @@ -0,0 +1,83 @@ +package manifest + +// Setup represents a set of service configuration that works with the code in +// the package. See https://www.fastly.com/documentation/reference/compute/fastly-toml. +type Setup struct { + Backends map[string]*SetupBackend `toml:"backends,omitempty"` + ConfigStores map[string]*SetupConfigStore `toml:"config_stores,omitempty"` + Loggers map[string]*SetupLogger `toml:"log_endpoints,omitempty"` + ObjectStores map[string]*SetupKVStore `toml:"object_stores,omitempty"` + KVStores map[string]*SetupKVStore `toml:"kv_stores,omitempty"` + SecretStores map[string]*SetupSecretStore `toml:"secret_stores,omitempty"` +} + +// Defined indicates if there is any [setup] configuration in the manifest. +func (s Setup) Defined() bool { + var defined bool + + if len(s.Backends) > 0 { + defined = true + } + if len(s.ConfigStores) > 0 { + defined = true + } + if len(s.Loggers) > 0 { + defined = true + } + if len(s.KVStores) > 0 { + defined = true + } + + return defined +} + +// SetupBackend represents a '[setup.backends.]' instance. +type SetupBackend struct { + Address string `toml:"address,omitempty"` + Port int `toml:"port,omitempty"` + Description string `toml:"description,omitempty"` +} + +// SetupConfigStore represents a '[setup.dictionaries.]' instance. +type SetupConfigStore struct { + Items map[string]SetupConfigStoreItems `toml:"items,omitempty"` + Description string `toml:"description,omitempty"` +} + +// SetupConfigStoreItems represents a '[setup.dictionaries..items]' instance. +type SetupConfigStoreItems struct { + Value string `toml:"value,omitempty"` + Description string `toml:"description,omitempty"` +} + +// SetupLogger represents a '[setup.log_endpoints.]' instance. +type SetupLogger struct { + Provider string `toml:"provider,omitempty"` +} + +// SetupKVStore represents a '[setup.kv_stores.]' instance. +type SetupKVStore struct { + Items map[string]SetupKVStoreItems `toml:"items,omitempty"` + Description string `toml:"description,omitempty"` +} + +// SetupKVStoreItems represents a '[setup.kv_stores..items]' instance. +type SetupKVStoreItems struct { + File string `toml:"file,omitempty"` + Value string `toml:"value,omitempty"` + Description string `toml:"description,omitempty"` +} + +// SetupSecretStore represents a '[setup.secret_stores.]' instance. +type SetupSecretStore struct { + Entries map[string]SetupSecretStoreEntry `toml:"entries,omitempty"` + Description string `toml:"description,omitempty"` +} + +// SetupSecretStoreEntry represents a '[setup.secret_stores..entries]' instance. +type SetupSecretStoreEntry struct { + // The secret value is intentionally omitted to avoid secrets + // from being included in the manifest. Instead, secret + // values are input during setup. + Description string `toml:"description,omitempty"` +} diff --git a/pkg/manifest/testdata/fastly-invalid-missing-version.toml b/pkg/manifest/testdata/fastly-invalid-missing-version.toml new file mode 100644 index 000000000..474fd2db4 --- /dev/null +++ b/pkg/manifest/testdata/fastly-invalid-missing-version.toml @@ -0,0 +1,4 @@ +name = "Default Rust template" +description = "Default package template for Rust based edge compute projects." +authors = ["phamann "] +language = "rust" diff --git a/pkg/manifest/testdata/fastly-invalid-unrecognised.toml b/pkg/manifest/testdata/fastly-invalid-unrecognised.toml new file mode 100644 index 000000000..bdd31f99f --- /dev/null +++ b/pkg/manifest/testdata/fastly-invalid-unrecognised.toml @@ -0,0 +1,6 @@ +# invalid: manifest_version is not a number +manifest_version = "abc" +name = "Default Rust template" +description = "Default package template for Rust based edge compute projects." +authors = ["phamann "] +language = "rust" diff --git a/pkg/manifest/testdata/fastly-invalid-version-exceeded.toml b/pkg/manifest/testdata/fastly-invalid-version-exceeded.toml new file mode 100644 index 000000000..370a1dd63 --- /dev/null +++ b/pkg/manifest/testdata/fastly-invalid-version-exceeded.toml @@ -0,0 +1,5 @@ +manifest_version = "99.0.0" # latest supported manifest_version is less than 99 +name = "Default Rust template" +description = "Default package template for Rust based edge compute projects." +authors = ["phamann "] +language = "rust" diff --git a/pkg/manifest/testdata/fastly-missing-spec-url.toml b/pkg/manifest/testdata/fastly-missing-spec-url.toml new file mode 100644 index 000000000..75770221e --- /dev/null +++ b/pkg/manifest/testdata/fastly-missing-spec-url.toml @@ -0,0 +1,5 @@ +manifest_version = 3 +name = "Default Rust template" +description = "Default package template for Rust based edge compute projects." +authors = ["phamann "] +language = "rust" diff --git a/pkg/manifest/testdata/fastly-valid-integer.toml b/pkg/manifest/testdata/fastly-valid-integer.toml new file mode 100644 index 000000000..75770221e --- /dev/null +++ b/pkg/manifest/testdata/fastly-valid-integer.toml @@ -0,0 +1,5 @@ +manifest_version = 3 +name = "Default Rust template" +description = "Default package template for Rust based edge compute projects." +authors = ["phamann "] +language = "rust" diff --git a/pkg/manifest/testdata/fastly-valid-semver.toml b/pkg/manifest/testdata/fastly-valid-semver.toml new file mode 100644 index 000000000..527ba8c77 --- /dev/null +++ b/pkg/manifest/testdata/fastly-valid-semver.toml @@ -0,0 +1,5 @@ +manifest_version = "0.99.0" # minor and patch versions are ignored and zero major is bumped to latest +name = "Default Rust template" +description = "Default package template for Rust based edge compute projects." +authors = ["phamann "] +language = "rust" diff --git a/pkg/manifest/testdata/fastly-viceroy-update.toml b/pkg/manifest/testdata/fastly-viceroy-update.toml new file mode 100644 index 000000000..c7e2e86ce --- /dev/null +++ b/pkg/manifest/testdata/fastly-viceroy-update.toml @@ -0,0 +1,67 @@ +# This file describes a Fastly Compute package. To learn more visit: +# https://www.fastly.com/documentation/reference/compute/fastly-toml + +authors = ["phamann "] +description = "Default package template for Rust based edge compute projects." +language = "rust" +manifest_version = 3 +name = "Default Rust template" + +[local_server] + +[local_server.backends] + +[local_server.backends.backend_a] +url = "https://example.com/" +override_host = "otherexample.com" + +[local_server.backends.foo] +url = "https://foo.com/" + +[local_server.backends.bar] +url = "https://bar.com/" + +[local_server.config_stores] + +[local_server.config_stores.strings] +file = "strings.json" +format = "json" + +[local_server.config_stores.example_store] +format = "inline-toml" + +[local_server.config_stores.example_store.contents] +foo = "bar" +baz = """ +qux""" + +[local_server.kv_stores] +store_one = [ + { key = "first", data = "This is some data", metadata = "This is some metadata" }, + { key = "second", file = "strings.json" }, +] +store_three = { file = "path/to/kv.json", format = "json" } + +[[local_server.kv_stores.store_two]] +key = "first" +data = "This is some data" +metadata = "This is some metadata" + +[[local_server.kv_stores.store_two]] +key = "second" +file = "strings.json" + +[local_server.secret_stores] +store_one = [ + { key = "first", data = "This is some secret data" }, + { key = "second", file = "/path/to/secret.json" }, +] +store_three = { file = "path/to/secret.json", format = "json" } + +[[local_server.secret_stores.store_two]] +key = "first" +data = "This is also some secret data" + +[[local_server.secret_stores.store_two]] +key = "second" +file = "/path/to/other/secret.json" diff --git a/pkg/manifest/testdata/fastly-warning-dictionaries.toml b/pkg/manifest/testdata/fastly-warning-dictionaries.toml new file mode 100644 index 000000000..1a5c2bff2 --- /dev/null +++ b/pkg/manifest/testdata/fastly-warning-dictionaries.toml @@ -0,0 +1,18 @@ +manifest_version = 3 +name = "Default Rust template" +description = "Default package template for Rust based edge compute projects." +authors = ["example "] +language = "rust" + +[setup.dictionaries] + +[setup.dictionaries.service_config] +description = "Configuration data for my service" + +[setup.dictionaries.service_config.items] + +[setup.dictionaries.service_config.items.s3-primary-host] +value = "eu-west-2" + +[setup.dictionaries.service_config.items.s3-fallback-host] +value = "us-west-1" diff --git a/pkg/manifest/version.go b/pkg/manifest/version.go new file mode 100644 index 000000000..7f9912fc9 --- /dev/null +++ b/pkg/manifest/version.go @@ -0,0 +1,66 @@ +package manifest + +import ( + "fmt" + "strconv" + "strings" + + fsterr "github.com/fastly/cli/pkg/errors" +) + +// Version represents the currently supported schema for the fastly.toml +// manifest file that determines the configuration for a Compute service. +// +// NOTE: the File object has a field called ManifestVersion which this type is +// assigned. The reason we don't name this type ManifestVersion is to appease +// the static analysis linter which complains re: stutter in the import +// manifest.ManifestVersion. +type Version int + +// UnmarshalText manages multiple scenarios where historically the manifest +// version was a string value and not an integer. +// +// Example mappings: +// +// "0.1.0" -> 1 +// "1" -> 1 +// 1 -> 1 +// "1.0.0" -> 1 +// 0.1 -> 1 +// "0.2.0" -> 1 +// "2.0.0" -> 2 +// +// We also constrain the version so that if a user has a manifest_version +// defined as "99.0.0" then we won't accidentally store it as the integer 99 +// but instead will return an error because it exceeds the current +// ManifestLatestVersion version. +func (v *Version) UnmarshalText(txt []byte) error { + s := string(txt) + + // Presumes semver value (e.g. 1.0.0, 0.1.0 or 0.1) + // Major is converted to integer if != zero. + // Otherwise if Major == zero, then ignore Minor/Patch and set to latest version. + var ( + err error + version int + ) + if strings.Contains(s, ".") { + segs := strings.Split(s, ".") + s = segs[0] + if s == "0" { + s = strconv.Itoa(ManifestLatestVersion) + } + } + + version, err = strconv.Atoi(s) + if err != nil { + return fmt.Errorf("error parsing manifest_version '%s': %w", s, err) + } + + if version > ManifestLatestVersion { + return fsterr.ErrUnrecognisedManifestVersion + } + + *v = Version(version) + return nil +} diff --git a/pkg/mock/api.go b/pkg/mock/api.go index b410a6903..0f584e3c3 100644 --- a/pkg/mock/api.go +++ b/pkg/mock/api.go @@ -1,16 +1,20 @@ package mock import ( - "github.com/fastly/go-fastly/v3/fastly" + "crypto/ed25519" + + "github.com/fastly/go-fastly/v10/fastly" ) // API is a mock implementation of api.Interface that's used for testing. // The zero value is useful, but will panic on all methods. Provide function // implementations for the method(s) your test will call. type API struct { - GetTokenSelfFn func() (*fastly.Token, error) + AllDatacentersFn func() (datacenters []fastly.Datacenter, err error) + AllIPsFn func() (v4, v6 fastly.IPAddrs, err error) CreateServiceFn func(*fastly.CreateServiceInput) (*fastly.Service, error) + GetServicesFn func(*fastly.GetServicesInput) *fastly.ListPaginator[fastly.Service] ListServicesFn func(*fastly.ListServicesInput) ([]*fastly.Service, error) GetServiceFn func(*fastly.GetServiceInput) (*fastly.Service, error) GetServiceDetailsFn func(*fastly.GetServiceInput) (*fastly.ServiceDetail, error) @@ -20,17 +24,20 @@ type API struct { CloneVersionFn func(*fastly.CloneVersionInput) (*fastly.Version, error) ListVersionsFn func(*fastly.ListVersionsInput) ([]*fastly.Version, error) + GetVersionFn func(*fastly.GetVersionInput) (*fastly.Version, error) UpdateVersionFn func(*fastly.UpdateVersionInput) (*fastly.Version, error) ActivateVersionFn func(*fastly.ActivateVersionInput) (*fastly.Version, error) DeactivateVersionFn func(*fastly.DeactivateVersionInput) (*fastly.Version, error) LockVersionFn func(*fastly.LockVersionInput) (*fastly.Version, error) LatestVersionFn func(*fastly.LatestVersionInput) (*fastly.Version, error) - CreateDomainFn func(*fastly.CreateDomainInput) (*fastly.Domain, error) - ListDomainsFn func(*fastly.ListDomainsInput) ([]*fastly.Domain, error) - GetDomainFn func(*fastly.GetDomainInput) (*fastly.Domain, error) - UpdateDomainFn func(*fastly.UpdateDomainInput) (*fastly.Domain, error) - DeleteDomainFn func(*fastly.DeleteDomainInput) error + CreateDomainFn func(*fastly.CreateDomainInput) (*fastly.Domain, error) + ListDomainsFn func(*fastly.ListDomainsInput) ([]*fastly.Domain, error) + GetDomainFn func(*fastly.GetDomainInput) (*fastly.Domain, error) + UpdateDomainFn func(*fastly.UpdateDomainInput) (*fastly.Domain, error) + DeleteDomainFn func(*fastly.DeleteDomainInput) error + ValidateDomainFn func(i *fastly.ValidateDomainInput) (*fastly.DomainValidationResult, error) + ValidateAllDomainsFn func(i *fastly.ValidateAllDomainsInput) (results []*fastly.DomainValidationResult, err error) CreateBackendFn func(*fastly.CreateBackendInput) (*fastly.Backend, error) ListBackendsFn func(*fastly.ListBackendsInput) ([]*fastly.Backend, error) @@ -53,6 +60,7 @@ type API struct { ListDictionariesFn func(*fastly.ListDictionariesInput) ([]*fastly.Dictionary, error) UpdateDictionaryFn func(*fastly.UpdateDictionaryInput) (*fastly.Dictionary, error) + GetDictionaryItemsFn func(*fastly.GetDictionaryItemsInput) *fastly.ListPaginator[fastly.DictionaryItem] ListDictionaryItemsFn func(*fastly.ListDictionaryItemsInput) ([]*fastly.DictionaryItem, error) GetDictionaryItemFn func(*fastly.GetDictionaryItemInput) (*fastly.DictionaryItem, error) CreateDictionaryItemFn func(*fastly.CreateDictionaryItemInput) (*fastly.DictionaryItem, error) @@ -110,6 +118,12 @@ type API struct { UpdateGCSFn func(*fastly.UpdateGCSInput) (*fastly.GCS, error) DeleteGCSFn func(*fastly.DeleteGCSInput) error + CreateGrafanaCloudLogsFn func(*fastly.CreateGrafanaCloudLogsInput) (*fastly.GrafanaCloudLogs, error) + ListGrafanaCloudLogsFn func(*fastly.ListGrafanaCloudLogsInput) ([]*fastly.GrafanaCloudLogs, error) + GetGrafanaCloudLogsFn func(*fastly.GetGrafanaCloudLogsInput) (*fastly.GrafanaCloudLogs, error) + UpdateGrafanaCloudLogsFn func(*fastly.UpdateGrafanaCloudLogsInput) (*fastly.GrafanaCloudLogs, error) + DeleteGrafanaCloudLogsFn func(*fastly.DeleteGrafanaCloudLogsInput) error + CreateFTPFn func(*fastly.CreateFTPInput) (*fastly.FTP, error) ListFTPsFn func(*fastly.ListFTPsInput) ([]*fastly.FTP, error) GetFTPFn func(*fastly.GetFTPInput) (*fastly.FTP, error) @@ -212,17 +226,194 @@ type API struct { UpdateOpenstackFn func(*fastly.UpdateOpenstackInput) (*fastly.Openstack, error) DeleteOpenstackFn func(*fastly.DeleteOpenstackInput) error - GetUserFn func(*fastly.GetUserInput) (*fastly.User, error) - GetRegionsFn func() (*fastly.RegionsResponse, error) - GetStatsJSONFn func(i *fastly.GetStatsInput, dst interface{}) error + GetStatsJSONFn func(i *fastly.GetStatsInput, dst any) error CreateManagedLoggingFn func(*fastly.CreateManagedLoggingInput) (*fastly.ManagedLogging, error) -} -// GetTokenSelf implements Interface. -func (m API) GetTokenSelf() (*fastly.Token, error) { - return m.GetTokenSelfFn() + CreateVCLFn func(*fastly.CreateVCLInput) (*fastly.VCL, error) + ListVCLsFn func(*fastly.ListVCLsInput) ([]*fastly.VCL, error) + GetVCLFn func(*fastly.GetVCLInput) (*fastly.VCL, error) + UpdateVCLFn func(*fastly.UpdateVCLInput) (*fastly.VCL, error) + DeleteVCLFn func(*fastly.DeleteVCLInput) error + + CreateSnippetFn func(i *fastly.CreateSnippetInput) (*fastly.Snippet, error) + ListSnippetsFn func(i *fastly.ListSnippetsInput) ([]*fastly.Snippet, error) + GetSnippetFn func(i *fastly.GetSnippetInput) (*fastly.Snippet, error) + GetDynamicSnippetFn func(i *fastly.GetDynamicSnippetInput) (*fastly.DynamicSnippet, error) + UpdateSnippetFn func(i *fastly.UpdateSnippetInput) (*fastly.Snippet, error) + UpdateDynamicSnippetFn func(i *fastly.UpdateDynamicSnippetInput) (*fastly.DynamicSnippet, error) + DeleteSnippetFn func(i *fastly.DeleteSnippetInput) error + + PurgeFn func(i *fastly.PurgeInput) (*fastly.Purge, error) + PurgeKeyFn func(i *fastly.PurgeKeyInput) (*fastly.Purge, error) + PurgeKeysFn func(i *fastly.PurgeKeysInput) (map[string]string, error) + PurgeAllFn func(i *fastly.PurgeAllInput) (*fastly.Purge, error) + + CreateACLFn func(i *fastly.CreateACLInput) (*fastly.ACL, error) + DeleteACLFn func(i *fastly.DeleteACLInput) error + GetACLFn func(i *fastly.GetACLInput) (*fastly.ACL, error) + ListACLsFn func(i *fastly.ListACLsInput) ([]*fastly.ACL, error) + UpdateACLFn func(i *fastly.UpdateACLInput) (*fastly.ACL, error) + + CreateACLEntryFn func(i *fastly.CreateACLEntryInput) (*fastly.ACLEntry, error) + DeleteACLEntryFn func(i *fastly.DeleteACLEntryInput) error + GetACLEntryFn func(i *fastly.GetACLEntryInput) (*fastly.ACLEntry, error) + GetACLEntriesFn func(i *fastly.GetACLEntriesInput) *fastly.ListPaginator[fastly.ACLEntry] + ListACLEntriesFn func(i *fastly.ListACLEntriesInput) ([]*fastly.ACLEntry, error) + UpdateACLEntryFn func(i *fastly.UpdateACLEntryInput) (*fastly.ACLEntry, error) + BatchModifyACLEntriesFn func(i *fastly.BatchModifyACLEntriesInput) error + + CreateNewRelicFn func(i *fastly.CreateNewRelicInput) (*fastly.NewRelic, error) + DeleteNewRelicFn func(i *fastly.DeleteNewRelicInput) error + GetNewRelicFn func(i *fastly.GetNewRelicInput) (*fastly.NewRelic, error) + ListNewRelicFn func(i *fastly.ListNewRelicInput) ([]*fastly.NewRelic, error) + UpdateNewRelicFn func(i *fastly.UpdateNewRelicInput) (*fastly.NewRelic, error) + + CreateNewRelicOTLPFn func(i *fastly.CreateNewRelicOTLPInput) (*fastly.NewRelicOTLP, error) + DeleteNewRelicOTLPFn func(i *fastly.DeleteNewRelicOTLPInput) error + GetNewRelicOTLPFn func(i *fastly.GetNewRelicOTLPInput) (*fastly.NewRelicOTLP, error) + ListNewRelicOTLPFn func(i *fastly.ListNewRelicOTLPInput) ([]*fastly.NewRelicOTLP, error) + UpdateNewRelicOTLPFn func(i *fastly.UpdateNewRelicOTLPInput) (*fastly.NewRelicOTLP, error) + + CreateUserFn func(i *fastly.CreateUserInput) (*fastly.User, error) + DeleteUserFn func(i *fastly.DeleteUserInput) error + GetCurrentUserFn func() (*fastly.User, error) + GetUserFn func(i *fastly.GetUserInput) (*fastly.User, error) + ListCustomerUsersFn func(i *fastly.ListCustomerUsersInput) ([]*fastly.User, error) + UpdateUserFn func(i *fastly.UpdateUserInput) (*fastly.User, error) + ResetUserPasswordFn func(i *fastly.ResetUserPasswordInput) error + + BatchDeleteTokensFn func(i *fastly.BatchDeleteTokensInput) error + CreateTokenFn func(i *fastly.CreateTokenInput) (*fastly.Token, error) + DeleteTokenFn func(i *fastly.DeleteTokenInput) error + DeleteTokenSelfFn func() error + GetTokenSelfFn func() (*fastly.Token, error) + ListCustomerTokensFn func(i *fastly.ListCustomerTokensInput) ([]*fastly.Token, error) + ListTokensFn func(i *fastly.ListTokensInput) ([]*fastly.Token, error) + + NewListKVStoreKeysPaginatorFn func(i *fastly.ListKVStoreKeysInput) fastly.PaginatorKVStoreEntries + + GetCustomTLSConfigurationFn func(i *fastly.GetCustomTLSConfigurationInput) (*fastly.CustomTLSConfiguration, error) + ListCustomTLSConfigurationsFn func(i *fastly.ListCustomTLSConfigurationsInput) ([]*fastly.CustomTLSConfiguration, error) + UpdateCustomTLSConfigurationFn func(i *fastly.UpdateCustomTLSConfigurationInput) (*fastly.CustomTLSConfiguration, error) + GetTLSActivationFn func(i *fastly.GetTLSActivationInput) (*fastly.TLSActivation, error) + ListTLSActivationsFn func(i *fastly.ListTLSActivationsInput) ([]*fastly.TLSActivation, error) + UpdateTLSActivationFn func(i *fastly.UpdateTLSActivationInput) (*fastly.TLSActivation, error) + CreateTLSActivationFn func(i *fastly.CreateTLSActivationInput) (*fastly.TLSActivation, error) + DeleteTLSActivationFn func(i *fastly.DeleteTLSActivationInput) error + + CreateCustomTLSCertificateFn func(i *fastly.CreateCustomTLSCertificateInput) (*fastly.CustomTLSCertificate, error) + DeleteCustomTLSCertificateFn func(i *fastly.DeleteCustomTLSCertificateInput) error + GetCustomTLSCertificateFn func(i *fastly.GetCustomTLSCertificateInput) (*fastly.CustomTLSCertificate, error) + ListCustomTLSCertificatesFn func(i *fastly.ListCustomTLSCertificatesInput) ([]*fastly.CustomTLSCertificate, error) + UpdateCustomTLSCertificateFn func(i *fastly.UpdateCustomTLSCertificateInput) (*fastly.CustomTLSCertificate, error) + + ListTLSDomainsFn func(i *fastly.ListTLSDomainsInput) ([]*fastly.TLSDomain, error) + + CreatePrivateKeyFn func(i *fastly.CreatePrivateKeyInput) (*fastly.PrivateKey, error) + DeletePrivateKeyFn func(i *fastly.DeletePrivateKeyInput) error + GetPrivateKeyFn func(i *fastly.GetPrivateKeyInput) (*fastly.PrivateKey, error) + ListPrivateKeysFn func(i *fastly.ListPrivateKeysInput) ([]*fastly.PrivateKey, error) + + CreateBulkCertificateFn func(i *fastly.CreateBulkCertificateInput) (*fastly.BulkCertificate, error) + DeleteBulkCertificateFn func(i *fastly.DeleteBulkCertificateInput) error + GetBulkCertificateFn func(i *fastly.GetBulkCertificateInput) (*fastly.BulkCertificate, error) + ListBulkCertificatesFn func(i *fastly.ListBulkCertificatesInput) ([]*fastly.BulkCertificate, error) + UpdateBulkCertificateFn func(i *fastly.UpdateBulkCertificateInput) (*fastly.BulkCertificate, error) + + CreateTLSSubscriptionFn func(i *fastly.CreateTLSSubscriptionInput) (*fastly.TLSSubscription, error) + DeleteTLSSubscriptionFn func(i *fastly.DeleteTLSSubscriptionInput) error + GetTLSSubscriptionFn func(i *fastly.GetTLSSubscriptionInput) (*fastly.TLSSubscription, error) + ListTLSSubscriptionsFn func(i *fastly.ListTLSSubscriptionsInput) ([]*fastly.TLSSubscription, error) + UpdateTLSSubscriptionFn func(i *fastly.UpdateTLSSubscriptionInput) (*fastly.TLSSubscription, error) + + ListServiceAuthorizationsFn func(i *fastly.ListServiceAuthorizationsInput) (*fastly.ServiceAuthorizations, error) + GetServiceAuthorizationFn func(i *fastly.GetServiceAuthorizationInput) (*fastly.ServiceAuthorization, error) + CreateServiceAuthorizationFn func(i *fastly.CreateServiceAuthorizationInput) (*fastly.ServiceAuthorization, error) + UpdateServiceAuthorizationFn func(i *fastly.UpdateServiceAuthorizationInput) (*fastly.ServiceAuthorization, error) + DeleteServiceAuthorizationFn func(i *fastly.DeleteServiceAuthorizationInput) error + + CreateConfigStoreFn func(i *fastly.CreateConfigStoreInput) (*fastly.ConfigStore, error) + DeleteConfigStoreFn func(i *fastly.DeleteConfigStoreInput) error + GetConfigStoreFn func(i *fastly.GetConfigStoreInput) (*fastly.ConfigStore, error) + GetConfigStoreMetadataFn func(i *fastly.GetConfigStoreMetadataInput) (*fastly.ConfigStoreMetadata, error) + ListConfigStoresFn func(i *fastly.ListConfigStoresInput) ([]*fastly.ConfigStore, error) + ListConfigStoreServicesFn func(i *fastly.ListConfigStoreServicesInput) ([]*fastly.Service, error) + UpdateConfigStoreFn func(i *fastly.UpdateConfigStoreInput) (*fastly.ConfigStore, error) + + CreateConfigStoreItemFn func(i *fastly.CreateConfigStoreItemInput) (*fastly.ConfigStoreItem, error) + DeleteConfigStoreItemFn func(i *fastly.DeleteConfigStoreItemInput) error + GetConfigStoreItemFn func(i *fastly.GetConfigStoreItemInput) (*fastly.ConfigStoreItem, error) + ListConfigStoreItemsFn func(i *fastly.ListConfigStoreItemsInput) ([]*fastly.ConfigStoreItem, error) + UpdateConfigStoreItemFn func(i *fastly.UpdateConfigStoreItemInput) (*fastly.ConfigStoreItem, error) + + CreateKVStoreFn func(i *fastly.CreateKVStoreInput) (*fastly.KVStore, error) + GetKVStoreFn func(i *fastly.GetKVStoreInput) (*fastly.KVStore, error) + ListKVStoresFn func(i *fastly.ListKVStoresInput) (*fastly.ListKVStoresResponse, error) + DeleteKVStoreFn func(i *fastly.DeleteKVStoreInput) error + ListKVStoreKeysFn func(i *fastly.ListKVStoreKeysInput) (*fastly.ListKVStoreKeysResponse, error) + GetKVStoreKeyFn func(i *fastly.GetKVStoreKeyInput) (string, error) + InsertKVStoreKeyFn func(i *fastly.InsertKVStoreKeyInput) error + DeleteKVStoreKeyFn func(i *fastly.DeleteKVStoreKeyInput) error + BatchModifyKVStoreKeyFn func(i *fastly.BatchModifyKVStoreKeyInput) error + + CreateSecretStoreFn func(i *fastly.CreateSecretStoreInput) (*fastly.SecretStore, error) + GetSecretStoreFn func(i *fastly.GetSecretStoreInput) (*fastly.SecretStore, error) + DeleteSecretStoreFn func(i *fastly.DeleteSecretStoreInput) error + ListSecretStoresFn func(i *fastly.ListSecretStoresInput) (*fastly.SecretStores, error) + CreateSecretFn func(i *fastly.CreateSecretInput) (*fastly.Secret, error) + GetSecretFn func(i *fastly.GetSecretInput) (*fastly.Secret, error) + DeleteSecretFn func(i *fastly.DeleteSecretInput) error + ListSecretsFn func(i *fastly.ListSecretsInput) (*fastly.Secrets, error) + CreateClientKeyFn func() (*fastly.ClientKey, error) + GetSigningKeyFn func() (ed25519.PublicKey, error) + + CreateResourceFn func(i *fastly.CreateResourceInput) (*fastly.Resource, error) + DeleteResourceFn func(i *fastly.DeleteResourceInput) error + GetResourceFn func(i *fastly.GetResourceInput) (*fastly.Resource, error) + ListResourcesFn func(i *fastly.ListResourcesInput) ([]*fastly.Resource, error) + UpdateResourceFn func(i *fastly.UpdateResourceInput) (*fastly.Resource, error) + + CreateERLFn func(i *fastly.CreateERLInput) (*fastly.ERL, error) + DeleteERLFn func(i *fastly.DeleteERLInput) error + GetERLFn func(i *fastly.GetERLInput) (*fastly.ERL, error) + ListERLsFn func(i *fastly.ListERLsInput) ([]*fastly.ERL, error) + UpdateERLFn func(i *fastly.UpdateERLInput) (*fastly.ERL, error) + + CreateConditionFn func(i *fastly.CreateConditionInput) (*fastly.Condition, error) + DeleteConditionFn func(i *fastly.DeleteConditionInput) error + GetConditionFn func(i *fastly.GetConditionInput) (*fastly.Condition, error) + ListConditionsFn func(i *fastly.ListConditionsInput) ([]*fastly.Condition, error) + UpdateConditionFn func(i *fastly.UpdateConditionInput) (*fastly.Condition, error) + + GetProductFn func(i *fastly.ProductEnablementInput) (*fastly.ProductEnablement, error) + EnableProductFn func(i *fastly.ProductEnablementInput) (*fastly.ProductEnablement, error) + DisableProductFn func(i *fastly.ProductEnablementInput) error + + ListAlertDefinitionsFn func(i *fastly.ListAlertDefinitionsInput) (*fastly.AlertDefinitionsResponse, error) + CreateAlertDefinitionFn func(i *fastly.CreateAlertDefinitionInput) (*fastly.AlertDefinition, error) + GetAlertDefinitionFn func(i *fastly.GetAlertDefinitionInput) (*fastly.AlertDefinition, error) + UpdateAlertDefinitionFn func(i *fastly.UpdateAlertDefinitionInput) (*fastly.AlertDefinition, error) + DeleteAlertDefinitionFn func(i *fastly.DeleteAlertDefinitionInput) error + TestAlertDefinitionFn func(i *fastly.TestAlertDefinitionInput) error + ListAlertHistoryFn func(i *fastly.ListAlertHistoryInput) (*fastly.AlertHistoryResponse, error) + + ListObservabilityCustomDashboardsFn func(i *fastly.ListObservabilityCustomDashboardsInput) (*fastly.ListDashboardsResponse, error) + CreateObservabilityCustomDashboardFn func(i *fastly.CreateObservabilityCustomDashboardInput) (*fastly.ObservabilityCustomDashboard, error) + GetObservabilityCustomDashboardFn func(i *fastly.GetObservabilityCustomDashboardInput) (*fastly.ObservabilityCustomDashboard, error) + UpdateObservabilityCustomDashboardFn func(i *fastly.UpdateObservabilityCustomDashboardInput) (*fastly.ObservabilityCustomDashboard, error) + DeleteObservabilityCustomDashboardFn func(i *fastly.DeleteObservabilityCustomDashboardInput) error +} + +// AllDatacenters implements Interface. +func (m API) AllDatacenters() ([]fastly.Datacenter, error) { + return m.AllDatacentersFn() +} + +// AllIPs implements Interface. +func (m API) AllIPs() (fastly.IPAddrs, fastly.IPAddrs, error) { + return m.AllIPsFn() } // CreateService implements Interface. @@ -230,6 +421,11 @@ func (m API) CreateService(i *fastly.CreateServiceInput) (*fastly.Service, error return m.CreateServiceFn(i) } +// GetServices implements Interface. +func (m API) GetServices(i *fastly.GetServicesInput) *fastly.ListPaginator[fastly.Service] { + return m.GetServicesFn(i) +} + // ListServices implements Interface. func (m API) ListServices(i *fastly.ListServicesInput) ([]*fastly.Service, error) { return m.ListServicesFn(i) @@ -270,6 +466,11 @@ func (m API) ListVersions(i *fastly.ListVersionsInput) ([]*fastly.Version, error return m.ListVersionsFn(i) } +// GetVersion implements Interface. +func (m API) GetVersion(i *fastly.GetVersionInput) (*fastly.Version, error) { + return m.GetVersionFn(i) +} + // UpdateVersion implements Interface. func (m API) UpdateVersion(i *fastly.UpdateVersionInput) (*fastly.Version, error) { return m.UpdateVersionFn(i) @@ -320,6 +521,16 @@ func (m API) DeleteDomain(i *fastly.DeleteDomainInput) error { return m.DeleteDomainFn(i) } +// ValidateDomain implements Interface. +func (m API) ValidateDomain(i *fastly.ValidateDomainInput) (*fastly.DomainValidationResult, error) { + return m.ValidateDomainFn(i) +} + +// ValidateAllDomains implements Interface. +func (m API) ValidateAllDomains(i *fastly.ValidateAllDomainsInput) (results []*fastly.DomainValidationResult, err error) { + return m.ValidateAllDomainsFn(i) +} + // CreateBackend implements Interface. func (m API) CreateBackend(i *fastly.CreateBackendInput) (*fastly.Backend, error) { return m.CreateBackendFn(i) @@ -405,6 +616,11 @@ func (m API) UpdateDictionary(i *fastly.UpdateDictionaryInput) (*fastly.Dictiona return m.UpdateDictionaryFn(i) } +// GetDictionaryItems implements Interface. +func (m API) GetDictionaryItems(i *fastly.GetDictionaryItemsInput) *fastly.ListPaginator[fastly.DictionaryItem] { + return m.GetDictionaryItemsFn(i) +} + // ListDictionaryItems implements Interface. func (m API) ListDictionaryItems(i *fastly.ListDictionaryItemsInput) ([]*fastly.DictionaryItem, error) { return m.ListDictionaryItemsFn(i) @@ -640,6 +856,31 @@ func (m API) DeleteGCS(i *fastly.DeleteGCSInput) error { return m.DeleteGCSFn(i) } +// CreateGrafanaCloudLogs implements Interface. +func (m API) CreateGrafanaCloudLogs(i *fastly.CreateGrafanaCloudLogsInput) (*fastly.GrafanaCloudLogs, error) { + return m.CreateGrafanaCloudLogsFn(i) +} + +// ListGrafanaCloudLogs implements Interface. +func (m API) ListGrafanaCloudLogs(i *fastly.ListGrafanaCloudLogsInput) ([]*fastly.GrafanaCloudLogs, error) { + return m.ListGrafanaCloudLogsFn(i) +} + +// GetGrafanaCloudLogs implements Interface. +func (m API) GetGrafanaCloudLogs(i *fastly.GetGrafanaCloudLogsInput) (*fastly.GrafanaCloudLogs, error) { + return m.GetGrafanaCloudLogsFn(i) +} + +// UpdateGrafanaCloudLogs implements Interface. +func (m API) UpdateGrafanaCloudLogs(i *fastly.UpdateGrafanaCloudLogsInput) (*fastly.GrafanaCloudLogs, error) { + return m.UpdateGrafanaCloudLogsFn(i) +} + +// DeleteGrafanaCloudLogs implements Interface. +func (m API) DeleteGrafanaCloudLogs(i *fastly.DeleteGrafanaCloudLogsInput) error { + return m.DeleteGrafanaCloudLogsFn(i) +} + // CreateFTP implements Interface. func (m API) CreateFTP(i *fastly.CreateFTPInput) (*fastly.FTP, error) { return m.CreateFTPFn(i) @@ -1065,18 +1306,13 @@ func (m API) DeleteOpenstack(i *fastly.DeleteOpenstackInput) error { return m.DeleteOpenstackFn(i) } -// GetUser implements Interface. -func (m API) GetUser(i *fastly.GetUserInput) (*fastly.User, error) { - return m.GetUserFn(i) -} - // GetRegions implements Interface. func (m API) GetRegions() (*fastly.RegionsResponse, error) { return m.GetRegionsFn() } // GetStatsJSON implements Interface. -func (m API) GetStatsJSON(i *fastly.GetStatsInput, dst interface{}) error { +func (m API) GetStatsJSON(i *fastly.GetStatsInput, dst any) error { return m.GetStatsJSONFn(i, dst) } @@ -1084,3 +1320,738 @@ func (m API) GetStatsJSON(i *fastly.GetStatsInput, dst interface{}) error { func (m API) CreateManagedLogging(i *fastly.CreateManagedLoggingInput) (*fastly.ManagedLogging, error) { return m.CreateManagedLoggingFn(i) } + +// CreateVCL implements Interface. +func (m API) CreateVCL(i *fastly.CreateVCLInput) (*fastly.VCL, error) { + return m.CreateVCLFn(i) +} + +// ListVCLs implements Interface. +func (m API) ListVCLs(i *fastly.ListVCLsInput) ([]*fastly.VCL, error) { + return m.ListVCLsFn(i) +} + +// GetVCL implements Interface. +func (m API) GetVCL(i *fastly.GetVCLInput) (*fastly.VCL, error) { + return m.GetVCLFn(i) +} + +// UpdateVCL implements Interface. +func (m API) UpdateVCL(i *fastly.UpdateVCLInput) (*fastly.VCL, error) { + return m.UpdateVCLFn(i) +} + +// DeleteVCL implements Interface. +func (m API) DeleteVCL(i *fastly.DeleteVCLInput) error { + return m.DeleteVCLFn(i) +} + +// CreateSnippet implements Interface. +func (m API) CreateSnippet(i *fastly.CreateSnippetInput) (*fastly.Snippet, error) { + return m.CreateSnippetFn(i) +} + +// ListSnippets implements Interface. +func (m API) ListSnippets(i *fastly.ListSnippetsInput) ([]*fastly.Snippet, error) { + return m.ListSnippetsFn(i) +} + +// GetSnippet implements Interface. +func (m API) GetSnippet(i *fastly.GetSnippetInput) (*fastly.Snippet, error) { + return m.GetSnippetFn(i) +} + +// GetDynamicSnippet implements Interface. +func (m API) GetDynamicSnippet(i *fastly.GetDynamicSnippetInput) (*fastly.DynamicSnippet, error) { + return m.GetDynamicSnippetFn(i) +} + +// UpdateSnippet implements Interface. +func (m API) UpdateSnippet(i *fastly.UpdateSnippetInput) (*fastly.Snippet, error) { + return m.UpdateSnippetFn(i) +} + +// UpdateDynamicSnippet implements Interface. +func (m API) UpdateDynamicSnippet(i *fastly.UpdateDynamicSnippetInput) (*fastly.DynamicSnippet, error) { + return m.UpdateDynamicSnippetFn(i) +} + +// DeleteSnippet implements Interface. +func (m API) DeleteSnippet(i *fastly.DeleteSnippetInput) error { + return m.DeleteSnippetFn(i) +} + +// Purge implements Interface. +func (m API) Purge(i *fastly.PurgeInput) (*fastly.Purge, error) { + return m.PurgeFn(i) +} + +// PurgeKey implements Interface. +func (m API) PurgeKey(i *fastly.PurgeKeyInput) (*fastly.Purge, error) { + return m.PurgeKeyFn(i) +} + +// PurgeKeys implements Interface. +func (m API) PurgeKeys(i *fastly.PurgeKeysInput) (map[string]string, error) { + return m.PurgeKeysFn(i) +} + +// PurgeAll implements Interface. +func (m API) PurgeAll(i *fastly.PurgeAllInput) (*fastly.Purge, error) { + return m.PurgeAllFn(i) +} + +// CreateACL implements Interface. +func (m API) CreateACL(i *fastly.CreateACLInput) (*fastly.ACL, error) { + return m.CreateACLFn(i) +} + +// DeleteACL implements Interface. +func (m API) DeleteACL(i *fastly.DeleteACLInput) error { + return m.DeleteACLFn(i) +} + +// GetACL implements Interface. +func (m API) GetACL(i *fastly.GetACLInput) (*fastly.ACL, error) { + return m.GetACLFn(i) +} + +// ListACLs implements Interface. +func (m API) ListACLs(i *fastly.ListACLsInput) ([]*fastly.ACL, error) { + return m.ListACLsFn(i) +} + +// UpdateACL implements Interface. +func (m API) UpdateACL(i *fastly.UpdateACLInput) (*fastly.ACL, error) { + return m.UpdateACLFn(i) +} + +// CreateACLEntry implements Interface. +func (m API) CreateACLEntry(i *fastly.CreateACLEntryInput) (*fastly.ACLEntry, error) { + return m.CreateACLEntryFn(i) +} + +// DeleteACLEntry implements Interface. +func (m API) DeleteACLEntry(i *fastly.DeleteACLEntryInput) error { + return m.DeleteACLEntryFn(i) +} + +// GetACLEntry implements Interface. +func (m API) GetACLEntry(i *fastly.GetACLEntryInput) (*fastly.ACLEntry, error) { + return m.GetACLEntryFn(i) +} + +// GetACLEntries implements Interface. +func (m API) GetACLEntries(i *fastly.GetACLEntriesInput) *fastly.ListPaginator[fastly.ACLEntry] { + return m.GetACLEntriesFn(i) +} + +// ListACLEntries implements Interface. +func (m API) ListACLEntries(i *fastly.ListACLEntriesInput) ([]*fastly.ACLEntry, error) { + return m.ListACLEntriesFn(i) +} + +// UpdateACLEntry implements Interface. +func (m API) UpdateACLEntry(i *fastly.UpdateACLEntryInput) (*fastly.ACLEntry, error) { + return m.UpdateACLEntryFn(i) +} + +// BatchModifyACLEntries implements Interface. +func (m API) BatchModifyACLEntries(i *fastly.BatchModifyACLEntriesInput) error { + return m.BatchModifyACLEntriesFn(i) +} + +// CreateNewRelic implements Interface. +func (m API) CreateNewRelic(i *fastly.CreateNewRelicInput) (*fastly.NewRelic, error) { + return m.CreateNewRelicFn(i) +} + +// DeleteNewRelic implements Interface. +func (m API) DeleteNewRelic(i *fastly.DeleteNewRelicInput) error { + return m.DeleteNewRelicFn(i) +} + +// GetNewRelic implements Interface. +func (m API) GetNewRelic(i *fastly.GetNewRelicInput) (*fastly.NewRelic, error) { + return m.GetNewRelicFn(i) +} + +// ListNewRelic implements Interface. +func (m API) ListNewRelic(i *fastly.ListNewRelicInput) ([]*fastly.NewRelic, error) { + return m.ListNewRelicFn(i) +} + +// UpdateNewRelic implements Interface. +func (m API) UpdateNewRelic(i *fastly.UpdateNewRelicInput) (*fastly.NewRelic, error) { + return m.UpdateNewRelicFn(i) +} + +// CreateNewRelicOTLP implements Interface. +func (m API) CreateNewRelicOTLP(i *fastly.CreateNewRelicOTLPInput) (*fastly.NewRelicOTLP, error) { + return m.CreateNewRelicOTLPFn(i) +} + +// DeleteNewRelicOTLP implements Interface. +func (m API) DeleteNewRelicOTLP(i *fastly.DeleteNewRelicOTLPInput) error { + return m.DeleteNewRelicOTLPFn(i) +} + +// GetNewRelicOTLP implements Interface. +func (m API) GetNewRelicOTLP(i *fastly.GetNewRelicOTLPInput) (*fastly.NewRelicOTLP, error) { + return m.GetNewRelicOTLPFn(i) +} + +// ListNewRelicOTLP implements Interface. +func (m API) ListNewRelicOTLP(i *fastly.ListNewRelicOTLPInput) ([]*fastly.NewRelicOTLP, error) { + return m.ListNewRelicOTLPFn(i) +} + +// UpdateNewRelicOTLP implements Interface. +func (m API) UpdateNewRelicOTLP(i *fastly.UpdateNewRelicOTLPInput) (*fastly.NewRelicOTLP, error) { + return m.UpdateNewRelicOTLPFn(i) +} + +// CreateUser implements Interface. +func (m API) CreateUser(i *fastly.CreateUserInput) (*fastly.User, error) { + return m.CreateUserFn(i) +} + +// DeleteUser implements Interface. +func (m API) DeleteUser(i *fastly.DeleteUserInput) error { + return m.DeleteUserFn(i) +} + +// GetCurrentUser implements Interface. +func (m API) GetCurrentUser() (*fastly.User, error) { + return m.GetCurrentUserFn() +} + +// GetUser implements Interface. +func (m API) GetUser(i *fastly.GetUserInput) (*fastly.User, error) { + return m.GetUserFn(i) +} + +// ListCustomerUsers implements Interface. +func (m API) ListCustomerUsers(i *fastly.ListCustomerUsersInput) ([]*fastly.User, error) { + return m.ListCustomerUsersFn(i) +} + +// UpdateUser implements Interface. +func (m API) UpdateUser(i *fastly.UpdateUserInput) (*fastly.User, error) { + return m.UpdateUserFn(i) +} + +// ResetUserPassword implements Interface. +func (m API) ResetUserPassword(i *fastly.ResetUserPasswordInput) error { + return m.ResetUserPasswordFn(i) +} + +// BatchDeleteTokens implements Interface. +func (m API) BatchDeleteTokens(i *fastly.BatchDeleteTokensInput) error { + return m.BatchDeleteTokensFn(i) +} + +// CreateToken implements Interface. +func (m API) CreateToken(i *fastly.CreateTokenInput) (*fastly.Token, error) { + return m.CreateTokenFn(i) +} + +// DeleteToken implements Interface. +func (m API) DeleteToken(i *fastly.DeleteTokenInput) error { + return m.DeleteTokenFn(i) +} + +// DeleteTokenSelf implements Interface. +func (m API) DeleteTokenSelf() error { + return m.DeleteTokenSelfFn() +} + +// GetTokenSelf implements Interface. +func (m API) GetTokenSelf() (*fastly.Token, error) { + return m.GetTokenSelfFn() +} + +// ListCustomerTokens implements Interface. +func (m API) ListCustomerTokens(i *fastly.ListCustomerTokensInput) ([]*fastly.Token, error) { + return m.ListCustomerTokensFn(i) +} + +// ListTokens implements Interface. +func (m API) ListTokens(i *fastly.ListTokensInput) ([]*fastly.Token, error) { + return m.ListTokensFn(i) +} + +// NewListKVStoreKeysPaginator implements Interface. +func (m API) NewListKVStoreKeysPaginator(i *fastly.ListKVStoreKeysInput) fastly.PaginatorKVStoreEntries { + return m.NewListKVStoreKeysPaginatorFn(i) +} + +// GetCustomTLSConfiguration implements Interface. +func (m API) GetCustomTLSConfiguration(i *fastly.GetCustomTLSConfigurationInput) (*fastly.CustomTLSConfiguration, error) { + return m.GetCustomTLSConfigurationFn(i) +} + +// ListCustomTLSConfigurations implements Interface. +func (m API) ListCustomTLSConfigurations(i *fastly.ListCustomTLSConfigurationsInput) ([]*fastly.CustomTLSConfiguration, error) { + return m.ListCustomTLSConfigurationsFn(i) +} + +// UpdateCustomTLSConfiguration implements Interface. +func (m API) UpdateCustomTLSConfiguration(i *fastly.UpdateCustomTLSConfigurationInput) (*fastly.CustomTLSConfiguration, error) { + return m.UpdateCustomTLSConfigurationFn(i) +} + +// GetTLSActivation implements Interface. +func (m API) GetTLSActivation(i *fastly.GetTLSActivationInput) (*fastly.TLSActivation, error) { + return m.GetTLSActivationFn(i) +} + +// ListTLSActivations implements Interface. +func (m API) ListTLSActivations(i *fastly.ListTLSActivationsInput) ([]*fastly.TLSActivation, error) { + return m.ListTLSActivationsFn(i) +} + +// UpdateTLSActivation implements Interface. +func (m API) UpdateTLSActivation(i *fastly.UpdateTLSActivationInput) (*fastly.TLSActivation, error) { + return m.UpdateTLSActivationFn(i) +} + +// CreateTLSActivation implements Interface. +func (m API) CreateTLSActivation(i *fastly.CreateTLSActivationInput) (*fastly.TLSActivation, error) { + return m.CreateTLSActivationFn(i) +} + +// DeleteTLSActivation implements Interface. +func (m API) DeleteTLSActivation(i *fastly.DeleteTLSActivationInput) error { + return m.DeleteTLSActivationFn(i) +} + +// CreateCustomTLSCertificate implements Interface. +func (m API) CreateCustomTLSCertificate(i *fastly.CreateCustomTLSCertificateInput) (*fastly.CustomTLSCertificate, error) { + return m.CreateCustomTLSCertificateFn(i) +} + +// DeleteCustomTLSCertificate implements Interface. +func (m API) DeleteCustomTLSCertificate(i *fastly.DeleteCustomTLSCertificateInput) error { + return m.DeleteCustomTLSCertificateFn(i) +} + +// GetCustomTLSCertificate implements Interface. +func (m API) GetCustomTLSCertificate(i *fastly.GetCustomTLSCertificateInput) (*fastly.CustomTLSCertificate, error) { + return m.GetCustomTLSCertificateFn(i) +} + +// ListCustomTLSCertificates implements Interface. +func (m API) ListCustomTLSCertificates(i *fastly.ListCustomTLSCertificatesInput) ([]*fastly.CustomTLSCertificate, error) { + return m.ListCustomTLSCertificatesFn(i) +} + +// UpdateCustomTLSCertificate implements Interface. +func (m API) UpdateCustomTLSCertificate(i *fastly.UpdateCustomTLSCertificateInput) (*fastly.CustomTLSCertificate, error) { + return m.UpdateCustomTLSCertificateFn(i) +} + +// ListTLSDomains implements Interface. +func (m API) ListTLSDomains(i *fastly.ListTLSDomainsInput) ([]*fastly.TLSDomain, error) { + return m.ListTLSDomainsFn(i) +} + +// CreatePrivateKey implements Interface. +func (m API) CreatePrivateKey(i *fastly.CreatePrivateKeyInput) (*fastly.PrivateKey, error) { + return m.CreatePrivateKeyFn(i) +} + +// DeletePrivateKey implements Interface. +func (m API) DeletePrivateKey(i *fastly.DeletePrivateKeyInput) error { + return m.DeletePrivateKeyFn(i) +} + +// GetPrivateKey implements Interface. +func (m API) GetPrivateKey(i *fastly.GetPrivateKeyInput) (*fastly.PrivateKey, error) { + return m.GetPrivateKeyFn(i) +} + +// ListPrivateKeys implements Interface. +func (m API) ListPrivateKeys(i *fastly.ListPrivateKeysInput) ([]*fastly.PrivateKey, error) { + return m.ListPrivateKeysFn(i) +} + +// CreateBulkCertificate implements Interface. +func (m API) CreateBulkCertificate(i *fastly.CreateBulkCertificateInput) (*fastly.BulkCertificate, error) { + return m.CreateBulkCertificateFn(i) +} + +// DeleteBulkCertificate implements Interface. +func (m API) DeleteBulkCertificate(i *fastly.DeleteBulkCertificateInput) error { + return m.DeleteBulkCertificateFn(i) +} + +// GetBulkCertificate implements Interface. +func (m API) GetBulkCertificate(i *fastly.GetBulkCertificateInput) (*fastly.BulkCertificate, error) { + return m.GetBulkCertificateFn(i) +} + +// ListBulkCertificates implements Interface. +func (m API) ListBulkCertificates(i *fastly.ListBulkCertificatesInput) ([]*fastly.BulkCertificate, error) { + return m.ListBulkCertificatesFn(i) +} + +// UpdateBulkCertificate implements Interface. +func (m API) UpdateBulkCertificate(i *fastly.UpdateBulkCertificateInput) (*fastly.BulkCertificate, error) { + return m.UpdateBulkCertificateFn(i) +} + +// CreateTLSSubscription implements Interface. +func (m API) CreateTLSSubscription(i *fastly.CreateTLSSubscriptionInput) (*fastly.TLSSubscription, error) { + return m.CreateTLSSubscriptionFn(i) +} + +// DeleteTLSSubscription implements Interface. +func (m API) DeleteTLSSubscription(i *fastly.DeleteTLSSubscriptionInput) error { + return m.DeleteTLSSubscriptionFn(i) +} + +// GetTLSSubscription implements Interface. +func (m API) GetTLSSubscription(i *fastly.GetTLSSubscriptionInput) (*fastly.TLSSubscription, error) { + return m.GetTLSSubscriptionFn(i) +} + +// ListTLSSubscriptions implements Interface. +func (m API) ListTLSSubscriptions(i *fastly.ListTLSSubscriptionsInput) ([]*fastly.TLSSubscription, error) { + return m.ListTLSSubscriptionsFn(i) +} + +// UpdateTLSSubscription implements Interface. +func (m API) UpdateTLSSubscription(i *fastly.UpdateTLSSubscriptionInput) (*fastly.TLSSubscription, error) { + return m.UpdateTLSSubscriptionFn(i) +} + +// ListServiceAuthorizations implements Interface. +func (m API) ListServiceAuthorizations(i *fastly.ListServiceAuthorizationsInput) (*fastly.ServiceAuthorizations, error) { + return m.ListServiceAuthorizationsFn(i) +} + +// GetServiceAuthorization implements Interface. +func (m API) GetServiceAuthorization(i *fastly.GetServiceAuthorizationInput) (*fastly.ServiceAuthorization, error) { + return m.GetServiceAuthorizationFn(i) +} + +// CreateServiceAuthorization implements Interface. +func (m API) CreateServiceAuthorization(i *fastly.CreateServiceAuthorizationInput) (*fastly.ServiceAuthorization, error) { + return m.CreateServiceAuthorizationFn(i) +} + +// UpdateServiceAuthorization implements Interface. +func (m API) UpdateServiceAuthorization(i *fastly.UpdateServiceAuthorizationInput) (*fastly.ServiceAuthorization, error) { + return m.UpdateServiceAuthorizationFn(i) +} + +// DeleteServiceAuthorization implements Interface. +func (m API) DeleteServiceAuthorization(i *fastly.DeleteServiceAuthorizationInput) error { + return m.DeleteServiceAuthorizationFn(i) +} + +// CreateConfigStore implements Interface. +func (m API) CreateConfigStore(i *fastly.CreateConfigStoreInput) (*fastly.ConfigStore, error) { + return m.CreateConfigStoreFn(i) +} + +// DeleteConfigStore implements Interface. +func (m API) DeleteConfigStore(i *fastly.DeleteConfigStoreInput) error { + return m.DeleteConfigStoreFn(i) +} + +// GetConfigStore implements Interface. +func (m API) GetConfigStore(i *fastly.GetConfigStoreInput) (*fastly.ConfigStore, error) { + return m.GetConfigStoreFn(i) +} + +// GetConfigStoreMetadata implements Interface. +func (m API) GetConfigStoreMetadata(i *fastly.GetConfigStoreMetadataInput) (*fastly.ConfigStoreMetadata, error) { + return m.GetConfigStoreMetadataFn(i) +} + +// ListConfigStores implements Interface. +func (m API) ListConfigStores(i *fastly.ListConfigStoresInput) ([]*fastly.ConfigStore, error) { + return m.ListConfigStoresFn(i) +} + +// ListConfigStoreServices implements Interface. +func (m API) ListConfigStoreServices(i *fastly.ListConfigStoreServicesInput) ([]*fastly.Service, error) { + return m.ListConfigStoreServicesFn(i) +} + +// UpdateConfigStore implements Interface. +func (m API) UpdateConfigStore(i *fastly.UpdateConfigStoreInput) (*fastly.ConfigStore, error) { + return m.UpdateConfigStoreFn(i) +} + +// CreateConfigStoreItem implements Interface. +func (m API) CreateConfigStoreItem(i *fastly.CreateConfigStoreItemInput) (*fastly.ConfigStoreItem, error) { + return m.CreateConfigStoreItemFn(i) +} + +// DeleteConfigStoreItem implements Interface. +func (m API) DeleteConfigStoreItem(i *fastly.DeleteConfigStoreItemInput) error { + return m.DeleteConfigStoreItemFn(i) +} + +// GetConfigStoreItem implements Interface. +func (m API) GetConfigStoreItem(i *fastly.GetConfigStoreItemInput) (*fastly.ConfigStoreItem, error) { + return m.GetConfigStoreItemFn(i) +} + +// ListConfigStoreItems implements Interface. +func (m API) ListConfigStoreItems(i *fastly.ListConfigStoreItemsInput) ([]*fastly.ConfigStoreItem, error) { + return m.ListConfigStoreItemsFn(i) +} + +// UpdateConfigStoreItem implements Interface. +func (m API) UpdateConfigStoreItem(i *fastly.UpdateConfigStoreItemInput) (*fastly.ConfigStoreItem, error) { + return m.UpdateConfigStoreItemFn(i) +} + +// CreateKVStore implements Interface. +func (m API) CreateKVStore(i *fastly.CreateKVStoreInput) (*fastly.KVStore, error) { + return m.CreateKVStoreFn(i) +} + +// GetKVStore implements Interface. +func (m API) GetKVStore(i *fastly.GetKVStoreInput) (*fastly.KVStore, error) { + return m.GetKVStoreFn(i) +} + +// ListKVStores implements Interface. +func (m API) ListKVStores(i *fastly.ListKVStoresInput) (*fastly.ListKVStoresResponse, error) { + return m.ListKVStoresFn(i) +} + +// DeleteKVStore implements Interface. +func (m API) DeleteKVStore(i *fastly.DeleteKVStoreInput) error { + return m.DeleteKVStoreFn(i) +} + +// ListKVStoreKeys implements Interface. +func (m API) ListKVStoreKeys(i *fastly.ListKVStoreKeysInput) (*fastly.ListKVStoreKeysResponse, error) { + return m.ListKVStoreKeysFn(i) +} + +// GetKVStoreKey implements Interface. +func (m API) GetKVStoreKey(i *fastly.GetKVStoreKeyInput) (string, error) { + return m.GetKVStoreKeyFn(i) +} + +// InsertKVStoreKey implements Interface. +func (m API) InsertKVStoreKey(i *fastly.InsertKVStoreKeyInput) error { + return m.InsertKVStoreKeyFn(i) +} + +// DeleteKVStoreKey implements Interface. +func (m API) DeleteKVStoreKey(i *fastly.DeleteKVStoreKeyInput) error { + return m.DeleteKVStoreKeyFn(i) +} + +// BatchModifyKVStoreKey implements Interface. +func (m API) BatchModifyKVStoreKey(i *fastly.BatchModifyKVStoreKeyInput) error { + return m.BatchModifyKVStoreKeyFn(i) +} + +// CreateSecretStore implements Interface. +func (m API) CreateSecretStore(i *fastly.CreateSecretStoreInput) (*fastly.SecretStore, error) { + return m.CreateSecretStoreFn(i) +} + +// GetSecretStore implements Interface. +func (m API) GetSecretStore(i *fastly.GetSecretStoreInput) (*fastly.SecretStore, error) { + return m.GetSecretStoreFn(i) +} + +// DeleteSecretStore implements Interface. +func (m API) DeleteSecretStore(i *fastly.DeleteSecretStoreInput) error { + return m.DeleteSecretStoreFn(i) +} + +// ListSecretStores implements Interface. +func (m API) ListSecretStores(i *fastly.ListSecretStoresInput) (*fastly.SecretStores, error) { + return m.ListSecretStoresFn(i) +} + +// CreateSecret implements Interface. +func (m API) CreateSecret(i *fastly.CreateSecretInput) (*fastly.Secret, error) { + return m.CreateSecretFn(i) +} + +// GetSecret implements Interface. +func (m API) GetSecret(i *fastly.GetSecretInput) (*fastly.Secret, error) { + return m.GetSecretFn(i) +} + +// DeleteSecret implements Interface. +func (m API) DeleteSecret(i *fastly.DeleteSecretInput) error { + return m.DeleteSecretFn(i) +} + +// ListSecrets implements Interface. +func (m API) ListSecrets(i *fastly.ListSecretsInput) (*fastly.Secrets, error) { + return m.ListSecretsFn(i) +} + +// CreateClientKey implements Interface. +func (m API) CreateClientKey() (*fastly.ClientKey, error) { + return m.CreateClientKeyFn() +} + +// GetSigningKey implements Interface. +func (m API) GetSigningKey() (ed25519.PublicKey, error) { + return m.GetSigningKeyFn() +} + +// CreateResource implements Interface. +func (m API) CreateResource(i *fastly.CreateResourceInput) (*fastly.Resource, error) { + return m.CreateResourceFn(i) +} + +// DeleteResource implements Interface. +func (m API) DeleteResource(i *fastly.DeleteResourceInput) error { + return m.DeleteResourceFn(i) +} + +// GetResource implements Interface. +func (m API) GetResource(i *fastly.GetResourceInput) (*fastly.Resource, error) { + return m.GetResourceFn(i) +} + +// ListResources implements Interface. +func (m API) ListResources(i *fastly.ListResourcesInput) ([]*fastly.Resource, error) { + return m.ListResourcesFn(i) +} + +// UpdateResource implements Interface. +func (m API) UpdateResource(i *fastly.UpdateResourceInput) (*fastly.Resource, error) { + return m.UpdateResourceFn(i) +} + +// CreateERL implements Interface. +func (m API) CreateERL(i *fastly.CreateERLInput) (*fastly.ERL, error) { + return m.CreateERLFn(i) +} + +// DeleteERL implements Interface. +func (m API) DeleteERL(i *fastly.DeleteERLInput) error { + return m.DeleteERLFn(i) +} + +// GetERL implements Interface. +func (m API) GetERL(i *fastly.GetERLInput) (*fastly.ERL, error) { + return m.GetERLFn(i) +} + +// ListERLs implements Interface. +func (m API) ListERLs(i *fastly.ListERLsInput) ([]*fastly.ERL, error) { + return m.ListERLsFn(i) +} + +// UpdateERL implements Interface. +func (m API) UpdateERL(i *fastly.UpdateERLInput) (*fastly.ERL, error) { + return m.UpdateERLFn(i) +} + +// CreateCondition implements Interface. +func (m API) CreateCondition(i *fastly.CreateConditionInput) (*fastly.Condition, error) { + return m.CreateConditionFn(i) +} + +// DeleteCondition implements Interface. +func (m API) DeleteCondition(i *fastly.DeleteConditionInput) error { + return m.DeleteConditionFn(i) +} + +// GetCondition implements Interface. +func (m API) GetCondition(i *fastly.GetConditionInput) (*fastly.Condition, error) { + return m.GetConditionFn(i) +} + +// ListConditions implements Interface. +func (m API) ListConditions(i *fastly.ListConditionsInput) ([]*fastly.Condition, error) { + return m.ListConditionsFn(i) +} + +// UpdateCondition implements Interface. +func (m API) UpdateCondition(i *fastly.UpdateConditionInput) (*fastly.Condition, error) { + return m.UpdateConditionFn(i) +} + +// GetProduct implements Interface. +func (m API) GetProduct(i *fastly.ProductEnablementInput) (*fastly.ProductEnablement, error) { + return m.GetProductFn(i) +} + +// EnableProduct implements Interface. +func (m API) EnableProduct(i *fastly.ProductEnablementInput) (*fastly.ProductEnablement, error) { + return m.EnableProductFn(i) +} + +// DisableProduct implements Interface. +func (m API) DisableProduct(i *fastly.ProductEnablementInput) error { + return m.DisableProductFn(i) +} + +// ListAlertDefinitions implements Interface. +func (m API) ListAlertDefinitions(i *fastly.ListAlertDefinitionsInput) (*fastly.AlertDefinitionsResponse, error) { + return m.ListAlertDefinitionsFn(i) +} + +// CreateAlertDefinition implements Interface. +func (m API) CreateAlertDefinition(i *fastly.CreateAlertDefinitionInput) (*fastly.AlertDefinition, error) { + return m.CreateAlertDefinitionFn(i) +} + +// GetAlertDefinition implements Interface. +func (m API) GetAlertDefinition(i *fastly.GetAlertDefinitionInput) (*fastly.AlertDefinition, error) { + return m.GetAlertDefinitionFn(i) +} + +// UpdateAlertDefinition implements Interface. +func (m API) UpdateAlertDefinition(i *fastly.UpdateAlertDefinitionInput) (*fastly.AlertDefinition, error) { + return m.UpdateAlertDefinitionFn(i) +} + +// DeleteAlertDefinition implements Interface. +func (m API) DeleteAlertDefinition(i *fastly.DeleteAlertDefinitionInput) error { + return m.DeleteAlertDefinitionFn(i) +} + +// TestAlertDefinition implements Interface. +func (m API) TestAlertDefinition(i *fastly.TestAlertDefinitionInput) error { + return m.TestAlertDefinitionFn(i) +} + +// ListAlertHistory implements Interface. +func (m API) ListAlertHistory(i *fastly.ListAlertHistoryInput) (*fastly.AlertHistoryResponse, error) { + return m.ListAlertHistoryFn(i) +} + +// CreateObservabilityCustomDashboard implements Interface. +func (m API) CreateObservabilityCustomDashboard(i *fastly.CreateObservabilityCustomDashboardInput) (*fastly.ObservabilityCustomDashboard, error) { + return m.CreateObservabilityCustomDashboardFn(i) +} + +// DeleteObservabilityCustomDashboard implements Interface. +func (m API) DeleteObservabilityCustomDashboard(i *fastly.DeleteObservabilityCustomDashboardInput) error { + return m.DeleteObservabilityCustomDashboardFn(i) +} + +// GetObservabilityCustomDashboard implements Interface. +func (m API) GetObservabilityCustomDashboard(i *fastly.GetObservabilityCustomDashboardInput) (*fastly.ObservabilityCustomDashboard, error) { + return m.GetObservabilityCustomDashboardFn(i) +} + +// ListObservabilityCustomDashboards implements Interface. +func (m API) ListObservabilityCustomDashboards(i *fastly.ListObservabilityCustomDashboardsInput) (*fastly.ListDashboardsResponse, error) { + return m.ListObservabilityCustomDashboardsFn(i) +} + +// UpdateObservabilityCustomDashboard implements Interface. +func (m API) UpdateObservabilityCustomDashboard(i *fastly.UpdateObservabilityCustomDashboardInput) (*fastly.ObservabilityCustomDashboard, error) { + return m.UpdateObservabilityCustomDashboardFn(i) +} diff --git a/pkg/mock/client.go b/pkg/mock/client.go index 79cb0302b..b8fea2a4f 100644 --- a/pkg/mock/client.go +++ b/pkg/mock/client.go @@ -1,13 +1,87 @@ package mock import ( + "bytes" + "context" + "fmt" + "io" + "net/http" + + "github.com/fastly/go-fastly/v10/fastly" + "github.com/fastly/cli/pkg/api" ) // APIClient takes a mock.API and returns an app.ClientFactory that uses that // mock, ignoring the token and endpoint. It should only be used for tests. -func APIClient(a API) func(string, string) (api.Interface, error) { - return func(token, endpoint string) (api.Interface, error) { +func APIClient(a API) func(string, string, bool) (api.Interface, error) { + return func(token, endpoint string, debugMode bool) (api.Interface, error) { + fmt.Printf("token: %s\n", token) + fmt.Printf("endpoint: %s\n", endpoint) + fmt.Printf("debugMode: %t\n", debugMode) return a, nil } } + +// HTTPClient is used to mock fastly.Client requests. +type HTTPClient struct { + // Index keeps track of which Responses/Errors index to return. + Index int + // Responses tracks different responses to return. + Responses []*http.Response + // Errors tracks different errors to return. + Errors []error + // SaveRequests toggles recording requests that pass through the + // client. + SaveRequests bool + // Requests stores copies of incoming requests. + Requests []http.Request +} + +// Get mocks a HTTP Client Get request. +func (c *HTTPClient) Get(p string, _ fastly.RequestOptions) (*http.Response, error) { + fmt.Printf("p: %#v\n", p) + // IMPORTANT: Have to increment on defer as index is already 0 by this point. + // This is opposite to the Do() method which is -1 at the time it's called. + defer func() { c.Index++ }() + return c.Responses[c.Index], c.Errors[c.Index] +} + +// Do mocks a HTTP Client Do operation. +func (c *HTTPClient) Do(r *http.Request) (*http.Response, error) { + fmt.Printf("r.URL: %#v\n", r.URL.String()) + fmt.Printf("r: %#v\n", r) + if c.SaveRequests { + c.Requests = append(c.Requests, *r.Clone(context.Background())) + } + c.Index++ + return c.Responses[c.Index], c.Errors[c.Index] +} + +// HTMLClient returns a mock HTTP Client that returns a stubbed response or +// error. +func HTMLClient(res []*http.Response, err []error) api.HTTPClient { + return &HTTPClient{ + Index: -1, + Responses: res, + Errors: err, + } +} + +// NewHTTPResponse fills in the boilerplate needed to create a minimal +// *http.Response. +func NewHTTPResponse(statusCode int, headers map[string]string, body io.ReadCloser) *http.Response { + if body == nil { + body = io.NopCloser(bytes.NewReader(nil)) + } + h := http.Header{} + for header, value := range headers { + h.Add(header, value) + } + return &http.Response{ + StatusCode: statusCode, + Status: http.StatusText(statusCode), + Body: body, + Header: h, + } +} diff --git a/pkg/mock/config_file.go b/pkg/mock/config_file.go index 0a29e1a68..f4728a2e8 100644 --- a/pkg/mock/config_file.go +++ b/pkg/mock/config_file.go @@ -5,8 +5,8 @@ package mock type ConfigFile struct { PathFn func() string ExistsFn func() bool - ReadFn func(c interface{}) error - WriteFn func(c interface{}) error + ReadFn func(c any) error + WriteFn func(c any) error } // Path satisfies the toml.ReadWriter interface for testing purposes. @@ -20,12 +20,12 @@ func (c *ConfigFile) Exists() bool { } // Read satisfies the toml.ReadWriter interface for testing purposes. -func (c *ConfigFile) Read(config interface{}) error { +func (c *ConfigFile) Read(config any) error { return c.ReadFn(config) } // Write satisfies the toml.ReadWriter interface for testing purposes. -func (c *ConfigFile) Write(config interface{}) error { +func (c *ConfigFile) Write(config any) error { return c.WriteFn(config) } @@ -33,7 +33,11 @@ func (c *ConfigFile) Write(config interface{}) error { // non-existent config file interface. func NewNonExistentConfigFile() *ConfigFile { return &ConfigFile{ - PathFn: func() string { return "" }, - ExistsFn: func() bool { return false }, + PathFn: func() string { + return "" + }, + ExistsFn: func() bool { + return false + }, } } diff --git a/pkg/mock/versioner.go b/pkg/mock/versioner.go index 53c36902d..97c9d5993 100644 --- a/pkg/mock/versioner.go +++ b/pkg/mock/versioner.go @@ -1,32 +1,60 @@ package mock -import ( - "context" - "fmt" - "strings" +import "fmt" - "github.com/blang/semver" - "github.com/fastly/cli/pkg/update" -) - -// Versioner mocks the update.Versioner interface. -type Versioner struct { - Version string - Error error +// AssetVersioner mocks the github.AssetVersioner interface. +type AssetVersioner struct { + AssetVersion string + BinaryFilename string + DownloadOK bool + DownloadedFile string + InstallFilePath string } -// Make sure mock.Versioner implements update.Versioner. -var _ update.Versioner = (*Versioner)(nil) +// BinaryName implements github.Versioner interface. +func (av AssetVersioner) BinaryName() string { + return av.BinaryFilename +} -// LatestVersion returns the parsed version field, or error if it's non-nil. -func (v Versioner) LatestVersion(context.Context) (semver.Version, error) { - if v.Error != nil { - return semver.Version{}, v.Error +// DownloadLatest implements github.Versioner interface. +func (av AssetVersioner) DownloadLatest() (string, error) { + if av.DownloadOK { + return av.DownloadedFile, nil } - return semver.Parse(strings.TrimPrefix(v.Version, "v")) + return "", fmt.Errorf("not implemented") +} + +// DownloadVersion implements github.Versioner interface. +func (av AssetVersioner) DownloadVersion(_ string) (string, error) { + return "", nil +} + +// Download implements github.Versioner interface. +func (av AssetVersioner) Download(_ string) (string, error) { + return "", nil +} + +// URL implements github.Versioner interface. +func (av AssetVersioner) URL() (string, error) { + return "", nil +} + +// LatestVersion implements github.Versioner interface. +func (av AssetVersioner) LatestVersion() (string, error) { + return av.AssetVersion, nil +} + +// RequestedVersion implements github.Versioner interface. +func (av AssetVersioner) RequestedVersion() (version string) { + return "" +} + +// SetRequestedVersion implements github.Versioner interface. +func (av AssetVersioner) SetRequestedVersion(_ string) { + // no-op } -// Download is a no-op. -func (v Versioner) Download(context.Context, semver.Version) (filename string, err error) { - return filename, fmt.Errorf("not implemented") +// InstallPath returns the location of where the binary should be installed. +func (av AssetVersioner) InstallPath() string { + return av.InstallFilePath } diff --git a/pkg/profile/doc.go b/pkg/profile/doc.go new file mode 100644 index 000000000..896444708 --- /dev/null +++ b/pkg/profile/doc.go @@ -0,0 +1,3 @@ +// Package profile contains functions and constants for managing user profile +// data. +package profile diff --git a/pkg/profile/profile.go b/pkg/profile/profile.go new file mode 100644 index 000000000..69343f2df --- /dev/null +++ b/pkg/profile/profile.go @@ -0,0 +1,118 @@ +package profile + +import ( + "github.com/fastly/cli/pkg/config" +) + +// DefaultName is the default profile name. +const DefaultName = "user" + +// DoesNotExist describes an output error/warning message. +const DoesNotExist = "the profile '%s' does not exist" + +// NoDefaults describes an output warning message. +const NoDefaults = "At least one account profile should be set as the 'default'. Run `fastly profile update ` and ensure the profile is set to be the default." + +// TokenExpired is a token expiration error message. +const TokenExpired = "the token in profile '%s' expired at '%s'" + +// TokenWillExpire is a token expiration error message. +const TokenWillExpire = "the token in profile '%s' will expire at '%s'" + +// Exist reports whether the given profile exists. +func Exist(name string, p config.Profiles) bool { + for k := range p { + if k == name { + return true + } + } + return false +} + +// Default returns the default profile (which is the active profile). +func Default(p config.Profiles) (string, *config.Profile) { + for k, v := range p { + if v.Default { + return k, v + } + } + return "", nil +} + +// Get returns the specified profile. +func Get(name string, p config.Profiles) *config.Profile { + for k, v := range p { + if k == name { + return v + } + } + return nil +} + +// SetDefault configures the named profile to be the default. +// +// NOTE: The type assigned to the config.Profiles map key value is a struct. +// Structs are passed by value and so we must return the mutated type. +func SetDefault(name string, p config.Profiles) (config.Profiles, bool) { + var ok bool + for k, v := range p { + v.Default = false + if k == name { + v.Default = true + ok = true + } + } + return p, ok +} + +// SetADefault sets one of the profiles to be the default. +// +// NOTE: This is used by the `sso` command. +// The reason it exists is because there could be profiles that for some reason +// the user has set them all to not be a default. So to avoid errors in the CLI +// we require at least one profile to be a default and this function makes it +// easy to just pick the first profile and generically set it as the default. +func SetADefault(p config.Profiles) (string, config.Profiles) { + var profileName string + for k, v := range p { + profileName = k + v.Default = true + break + } + return profileName, p +} + +// Delete removes the named profile from the profile configuration. +func Delete(name string, p config.Profiles) bool { + var ok bool + for k := range p { + if k == name { + delete(p, k) + ok = true + } + } + return ok +} + +// EditOption lets callers of Edit specify profile fields to update. +type EditOption func(*config.Profile) + +// Edit modifies the named profile. +// +// IMPORTANT: We must return config.Profiles to safely update in-memory data. +// The type assigned to the config.Profiles map key value is a struct and +// structs are passed by value, so we must return the mutated type so the +// caller so they can reassign the updated struct back to the in-memory data +// and then persist that data back to disk. +func Edit(name string, p config.Profiles, opts ...EditOption) (config.Profiles, bool) { + var ok bool + for k, v := range p { + if k == name { + for _, opt := range opts { + opt(v) + } + ok = true + } + } + return p, ok +} diff --git a/pkg/revision/revision.go b/pkg/revision/revision.go index cb8369b92..0ca364cf8 100644 --- a/pkg/revision/revision.go +++ b/pkg/revision/revision.go @@ -1,21 +1,39 @@ -// Package revision defines variables that will be populated with values from -// the Makefile at build time via LDFLAGS. +// Package revision defines variables that will be populated with values +// specified at build time via LDFLAGS. goreleaser will prompt for missing env +// variables. +// For more details on LDFLAGS: +// https://github.com/golang/go/wiki/GcToolchainTricks#including-build-information-in-the-executable package revision -import "strings" +import ( + "fmt" + "runtime" + "strings" +) var ( // AppVersion is the semver for this version of the client, or - // "v0.0.0-unknown". Set by `make release`. + // "v0.0.0-unknown". Handled by goreleaser. AppVersion string // GitCommit is the short git SHA associated with this build, or - // "unknown". Set by `make release`. + // "unknown". Handled by goreleaser. GitCommit string - // GoVersion is the output of `go version` associated with this build, or - // "go version unknown". Set by `make release`. + // GoVersion - Prefer letting the code handle this and set GoHostOS and + // GoHostArc instead. It can be set to the build host's `go version` output. GoVersion string + + // GoHostOS is the value from `runtime.GOOS`. + GoHostOS string + + // GoHostArch is the value from `runtime.GOARCH`. + GoHostArch string + + // Environment is set to either "development" (when working locally) or + // "release" when the code being executed is from a published release. + // Handled by goreleaser. + Environment string ) // None is the AppVersion string for local (unversioned) builds. @@ -28,8 +46,14 @@ func init() { if GitCommit == "" { GitCommit = "unknown" } + GoHostOS = runtime.GOOS + GoHostArch = runtime.GOARCH if GoVersion == "" { - GoVersion = "go version unknown" + // runtime.Version() provides the Go tree's version string at build time + GoVersion = fmt.Sprintf("go version %s %s/%s", runtime.Version(), GoHostOS, GoHostArch) + } + if Environment == "" { + Environment = "development" } } @@ -37,7 +61,7 @@ func init() { // `v` and also has a commit hash following the semantic version, and returns // just the semantic version. // -// e.g. v1.0.0-xyz --> 1.0.0 +// e.g. `v1.0.0-xyz` --> `1.0.0`. func SemVer(av string) string { av = strings.TrimPrefix(av, "v") seg := strings.Split(av, "-") diff --git a/pkg/runtime/doc.go b/pkg/runtime/doc.go new file mode 100644 index 000000000..4be4a1976 --- /dev/null +++ b/pkg/runtime/doc.go @@ -0,0 +1,2 @@ +// Package runtime contains variables for handling runtime information. +package runtime diff --git a/pkg/runtime/runtime.go b/pkg/runtime/runtime.go new file mode 100644 index 000000000..7967e1585 --- /dev/null +++ b/pkg/runtime/runtime.go @@ -0,0 +1,11 @@ +package runtime + +import "runtime" + +// Windows indicates if the CLI binary's runtime OS is Windows. +// +// NOTE: We use the same conditional check multiple times across the code base +// and I noticed I had a typo in a few instances where I had omitted the "s" at +// the end of "window" which meant the conditional failed to match when running +// on Windows. So this avoids that issue in case we need to add more uses of it. +var Windows = runtime.GOOS == "windows" diff --git a/pkg/service/create.go b/pkg/service/create.go deleted file mode 100644 index ab0d42909..000000000 --- a/pkg/service/create.go +++ /dev/null @@ -1,38 +0,0 @@ -package service - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// CreateCommand calls the Fastly API to create services. -type CreateCommand struct { - common.Base - Input fastly.CreateServiceInput -} - -// NewCreateCommand returns a usable command registered under the parent. -func NewCreateCommand(parent common.Registerer, globals *config.Data) *CreateCommand { - var c CreateCommand - c.Globals = globals - c.CmdClause = parent.Command("create", "Create a Fastly service").Alias("add") - c.CmdClause.Flag("name", "Service name").Short('n').Required().StringVar(&c.Input.Name) - c.CmdClause.Flag("type", `Service type. Can be one of "wasm" or "vcl", defaults to "wasm".`).Default("wasm").EnumVar(&c.Input.Type, "wasm", "vcl") - c.CmdClause.Flag("comment", "Human-readable comment").StringVar(&c.Input.Comment) - return &c -} - -// Exec invokes the application logic for the command. -func (c *CreateCommand) Exec(in io.Reader, out io.Writer) error { - s, err := c.Globals.Client.CreateService(&c.Input) - if err != nil { - return err - } - - text.Success(out, "Created service %s", s.ID) - return nil -} diff --git a/pkg/service/delete.go b/pkg/service/delete.go deleted file mode 100644 index 0631722bd..000000000 --- a/pkg/service/delete.go +++ /dev/null @@ -1,83 +0,0 @@ -package service - -import ( - "fmt" - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// DeleteCommand calls the Fastly API to delete services. -type DeleteCommand struct { - common.Base - manifest manifest.Data - Input fastly.DeleteServiceInput - force bool -} - -// NewDeleteCommand returns a usable command registered under the parent. -func NewDeleteCommand(parent common.Registerer, globals *config.Data) *DeleteCommand { - var c DeleteCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("delete", "Delete a Fastly service").Alias("remove") - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("force", "Force deletion of an active service").Short('f').BoolVar(&c.force) - return &c -} - -// Exec invokes the application logic for the command. -func (c *DeleteCommand) Exec(in io.Reader, out io.Writer) error { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - c.Input.ID = serviceID - - if c.force { - s, err := c.Globals.Client.GetServiceDetails(&fastly.GetServiceInput{ - ID: serviceID, - }) - if err != nil { - return err - } - - if s.ActiveVersion.Number != 0 { - _, err := c.Globals.Client.DeactivateVersion(&fastly.DeactivateVersionInput{ - ServiceID: serviceID, - ServiceVersion: s.ActiveVersion.Number, - }) - if err != nil { - return err - } - } - } - - if err := c.Globals.Client.DeleteService(&c.Input); err != nil { - return errors.RemediationError{ - Inner: err, - Remediation: fmt.Sprintf("Try %s\n", text.Bold("fastly service delete --force")), - } - } - - // Ensure that VCL service users are unaffected by checking if the Service ID - // was acquired via the fastly.toml manifest. - if source == manifest.SourceFile { - if err := c.manifest.File.Read(manifest.Filename); err != nil { - return fmt.Errorf("error reading package manifest: %w", err) - } - c.manifest.File.ServiceID = "" - if err := c.manifest.File.Write(manifest.Filename); err != nil { - return fmt.Errorf("error updating package manifest: %w", err) - } - } - - text.Success(out, "Deleted service ID %s", c.Input.ID) - return nil -} diff --git a/pkg/service/describe.go b/pkg/service/describe.go deleted file mode 100644 index 9487b5791..000000000 --- a/pkg/service/describe.go +++ /dev/null @@ -1,47 +0,0 @@ -package service - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// DescribeCommand calls the Fastly API to describe a service. -type DescribeCommand struct { - common.Base - manifest manifest.Data - Input fastly.GetServiceInput -} - -// NewDescribeCommand returns a usable command registered under the parent. -func NewDescribeCommand(parent common.Registerer, globals *config.Data) *DescribeCommand { - var c DescribeCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("describe", "Show detailed information about a Fastly service").Alias("get") - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - return &c -} - -// Exec invokes the application logic for the command. -func (c *DescribeCommand) Exec(in io.Reader, out io.Writer) error { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - c.Input.ID = serviceID - - service, err := c.Globals.Client.GetServiceDetails(&c.Input) - if err != nil { - return err - } - - text.PrintServiceDetail(out, "", service) - return nil -} diff --git a/pkg/service/list.go b/pkg/service/list.go deleted file mode 100644 index b28cd809a..000000000 --- a/pkg/service/list.go +++ /dev/null @@ -1,64 +0,0 @@ -package service - -import ( - "fmt" - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// ListCommand calls the Fastly API to list services. -type ListCommand struct { - common.Base - Input fastly.ListServicesInput -} - -// NewListCommand returns a usable command registered under the parent. -func NewListCommand(parent common.Registerer, globals *config.Data) *ListCommand { - var c ListCommand - c.Globals = globals - c.CmdClause = parent.Command("list", "List Fastly services") - // no flags, because ListServicesInput has no fields - return &c -} - -// Exec invokes the application logic for the command. -func (c *ListCommand) Exec(in io.Reader, out io.Writer) error { - services, err := c.Globals.Client.ListServices(&c.Input) - if err != nil { - return err - } - - if !c.Globals.Verbose() { - tw := text.NewTable(out) - tw.AddHeader("NAME", "ID", "TYPE", "ACTIVE VERSION", "LAST EDITED (UTC)") - for _, service := range services { - updatedAt := "n/a" - if service.UpdatedAt != nil { - updatedAt = service.UpdatedAt.UTC().Format(common.TimeFormat) - } - - activeVersion := fmt.Sprint(service.ActiveVersion) - for _, v := range service.Versions { - if uint(v.Number) == service.ActiveVersion && !v.Active { - activeVersion = "n/a" - } - } - - tw.AddLine(service.Name, service.ID, text.ServiceType(service.Type), activeVersion, updatedAt) - } - tw.Print() - return nil - } - - for i, service := range services { - fmt.Fprintf(out, "Service %d/%d\n", i+1, len(services)) - text.PrintService(out, "\t", service) - fmt.Fprintln(out) - } - - return nil -} diff --git a/pkg/service/root.go b/pkg/service/root.go deleted file mode 100644 index f4dcb7b62..000000000 --- a/pkg/service/root.go +++ /dev/null @@ -1,28 +0,0 @@ -package service - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/config" -) - -// RootCommand is the parent command for all subcommands in this package. -// It should be installed under the primary root command. -type RootCommand struct { - common.Base - // no flags -} - -// NewRootCommand returns a new command registered in the parent. -func NewRootCommand(parent common.Registerer, globals *config.Data) *RootCommand { - var c RootCommand - c.Globals = globals - c.CmdClause = parent.Command("service", "Manipulate Fastly services") - return &c -} - -// Exec implements the command interface. -func (c *RootCommand) Exec(in io.Reader, out io.Writer) error { - panic("unreachable") -} diff --git a/pkg/service/search.go b/pkg/service/search.go deleted file mode 100644 index d657626fb..000000000 --- a/pkg/service/search.go +++ /dev/null @@ -1,40 +0,0 @@ -package service - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// SearchCommand calls the Fastly API to describe a service. -type SearchCommand struct { - common.Base - manifest manifest.Data - Input fastly.SearchServiceInput -} - -// NewSearchCommand returns a usable command registered under the parent. -func NewSearchCommand(parent common.Registerer, globals *config.Data) *SearchCommand { - var c SearchCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("search", "Search for a Fastly service by name") - c.CmdClause.Flag("name", "Service name").Short('n').StringVar(&c.Input.Name) - return &c -} - -// Exec invokes the application logic for the command. -func (c *SearchCommand) Exec(in io.Reader, out io.Writer) error { - service, err := c.Globals.Client.SearchService(&c.Input) - if err != nil { - return err - } - - text.PrintService(out, "", service) - return nil -} diff --git a/pkg/service/service_test.go b/pkg/service/service_test.go deleted file mode 100644 index 3178d7644..000000000 --- a/pkg/service/service_test.go +++ /dev/null @@ -1,829 +0,0 @@ -package service_test - -import ( - "bytes" - "errors" - "io" - "net/http" - "os" - "path/filepath" - "regexp" - "strings" - "testing" - - "github.com/fastly/cli/pkg/app" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/mock" - "github.com/fastly/cli/pkg/testutil" - "github.com/fastly/cli/pkg/update" - "github.com/fastly/go-fastly/v3/fastly" -) - -func TestServiceCreate(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"service", "create"}, - api: mock.API{CreateServiceFn: createServiceOK}, - wantError: "error parsing arguments: required flag --name not provided", - }, - { - args: []string{"service", "create", "--name", "Foo"}, - api: mock.API{CreateServiceFn: createServiceOK}, - wantOutput: "Created service 12345", - }, - { - args: []string{"service", "create", "-n=Foo"}, - api: mock.API{CreateServiceFn: createServiceOK}, - wantOutput: "Created service 12345", - }, - { - args: []string{"service", "create", "--name", "Foo", "--type", "wasm"}, - api: mock.API{CreateServiceFn: createServiceOK}, - wantOutput: "Created service 12345", - }, - { - args: []string{"service", "create", "--name", "Foo", "--type", "wasm", "--comment", "Hello"}, - api: mock.API{CreateServiceFn: createServiceOK}, - wantOutput: "Created service 12345", - }, - { - args: []string{"service", "create", "-n", "Foo", "--comment", "Hello"}, - api: mock.API{CreateServiceFn: createServiceOK}, - wantOutput: "Created service 12345", - }, - { - args: []string{"service", "create", "-n", "Foo"}, - api: mock.API{CreateServiceFn: createServiceError}, - wantError: errTest.Error(), - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - configFileName = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, configFileName, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertStringContains(t, out.String(), testcase.wantOutput) - }) - } -} - -func TestServiceList(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"service", "list"}, - api: mock.API{ListServicesFn: listServicesOK}, - wantOutput: listServicesShortOutput, - }, - { - args: []string{"service", "list", "--verbose"}, - api: mock.API{ListServicesFn: listServicesOK}, - wantOutput: listServicesVerboseOutput, - }, - { - args: []string{"service", "list", "-v"}, - api: mock.API{ListServicesFn: listServicesOK}, - wantOutput: listServicesVerboseOutput, - }, - { - args: []string{"service", "--verbose", "list"}, - api: mock.API{ListServicesFn: listServicesOK}, - wantOutput: listServicesVerboseOutput, - }, - { - args: []string{"-v", "service", "list"}, - api: mock.API{ListServicesFn: listServicesOK}, - wantOutput: listServicesVerboseOutput, - }, - { - args: []string{"service", "list"}, - api: mock.API{ListServicesFn: listServicesError}, - wantError: errTest.Error(), - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - configFileName = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, configFileName, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertString(t, testcase.wantOutput, out.String()) - }) - } -} - -func TestServiceDescribe(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"service", "describe"}, - api: mock.API{GetServiceDetailsFn: describeServiceOK}, - wantError: "error reading service: no service ID found", - }, - { - args: []string{"service", "describe", "--service-id", "123"}, - api: mock.API{GetServiceDetailsFn: describeServiceOK}, - wantOutput: describeServiceShortOutput, - }, - { - args: []string{"service", "describe", "--service-id", "123", "--verbose"}, - api: mock.API{GetServiceDetailsFn: describeServiceOK}, - wantOutput: describeServiceVerboseOutput, - }, - { - args: []string{"service", "describe", "--service-id", "123", "-v"}, - api: mock.API{GetServiceDetailsFn: describeServiceOK}, - wantOutput: describeServiceVerboseOutput, - }, - { - args: []string{"service", "--verbose", "describe", "--service-id", "123"}, - api: mock.API{GetServiceDetailsFn: describeServiceOK}, - wantOutput: describeServiceVerboseOutput, - }, - { - args: []string{"-v", "service", "describe", "--service-id", "123"}, - api: mock.API{GetServiceDetailsFn: describeServiceOK}, - wantOutput: describeServiceVerboseOutput, - }, - { - args: []string{"service", "describe", "--service-id", "123"}, - api: mock.API{GetServiceDetailsFn: describeServiceError}, - wantError: errTest.Error(), - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertString(t, testcase.wantOutput, out.String()) - }) - } -} - -func TestServiceSearch(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"service", "search", "--name", "Foo"}, - api: mock.API{SearchServiceFn: searchServiceOK}, - wantOutput: searchServiceShortOutput, - }, - { - args: []string{"service", "search", "--name", "Foo", "-v"}, - api: mock.API{SearchServiceFn: searchServiceOK}, - wantOutput: searchServiceVerboseOutput, - }, - { - args: []string{"service", "search", "--name"}, - api: mock.API{SearchServiceFn: searchServiceOK}, - wantError: "error parsing arguments: expected argument for flag '--name'", - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertString(t, testcase.wantOutput, out.String()) - }) - } -} - -func TestServiceUpdate(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"service", "update"}, - api: mock.API{ - GetServiceFn: getServiceOK, - UpdateServiceFn: updateServiceOK, - }, - wantError: "error reading service: no service ID found", - }, - { - args: []string{"service", "update", "--service-id", "12345"}, - api: mock.API{UpdateServiceFn: updateServiceOK}, - wantError: "error parsing arguments: must provide either --name or --comment to update service", - }, - { - args: []string{"service", "update", "--service-id", "12345", "--name", "Foo"}, - api: mock.API{UpdateServiceFn: updateServiceOK}, - wantOutput: "Updated service 12345", - }, - { - args: []string{"service", "update", "--service-id", "12345", "-n=Foo"}, - api: mock.API{UpdateServiceFn: updateServiceOK}, - wantOutput: "Updated service 12345", - }, - { - args: []string{"service", "update", "--service-id", "12345", "--name", "Foo"}, - api: mock.API{UpdateServiceFn: updateServiceOK}, - wantOutput: "Updated service 12345", - }, - { - args: []string{"service", "update", "--service-id", "12345", "--name", "Foo", "--comment", "Hello"}, - api: mock.API{UpdateServiceFn: updateServiceOK}, - wantOutput: "Updated service 12345", - }, - { - args: []string{"service", "update", "--service-id", "12345", "-n", "Foo", "--comment", "Hello"}, - api: mock.API{UpdateServiceFn: updateServiceOK}, - wantOutput: "Updated service 12345", - }, - { - args: []string{"service", "update", "--service-id", "12345", "-n", "Foo"}, - api: mock.API{UpdateServiceFn: updateServiceError}, - wantError: errTest.Error(), - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - configFileName = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, configFileName, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertStringContains(t, out.String(), testcase.wantOutput) - }) - } -} - -func TestServiceDelete(t *testing.T) { - nonEmptyServiceID := regexp.MustCompile(`service_id = "[^"]+"`) - - for _, testcase := range []struct { - args []string - api mock.API - manifest string - wantError string - wantOutput string - expectEmptyServiceID bool - }{ - { - args: []string{"service", "delete"}, - api: mock.API{DeleteServiceFn: deleteServiceOK}, - manifest: "fastly-no-serviceid.toml", - wantError: "error reading service: no service ID found", - }, - { - args: []string{"service", "delete"}, - api: mock.API{DeleteServiceFn: deleteServiceOK}, - manifest: "fastly-valid.toml", - wantOutput: "Deleted service ID 123", - expectEmptyServiceID: true, - }, - { - args: []string{"service", "delete", "--service-id", "001"}, - api: mock.API{DeleteServiceFn: deleteServiceOK}, - wantOutput: "Deleted service ID 001", - }, - { - args: []string{"service", "delete", "--service-id", "001"}, - api: mock.API{DeleteServiceFn: deleteServiceOK}, - manifest: "fastly-valid.toml", - wantOutput: "Deleted service ID 001", - expectEmptyServiceID: false, - }, - { - args: []string{"service", "delete", "--service-id", "001"}, - api: mock.API{DeleteServiceFn: deleteServiceError}, - manifest: "fastly-valid.toml", - wantError: errTest.Error(), - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - // We're going to chdir to an temp environment, - // so save the PWD to return to, afterwards. - pwd, err := os.Getwd() - if err != nil { - t.Fatal(err) - } - - // Create our init environment in a temp dir. - // Defer a call to clean it up. - rootdir := makeTempEnvironment(t, testcase.manifest) - defer os.RemoveAll(rootdir) - - // Before running the test, chdir into the temp environment. - // When we're done, chdir back to our original location. - // This is so we can reliably assert file structure. - if err := os.Chdir(rootdir); err != nil { - t.Fatal(err) - } - defer os.Chdir(pwd) - - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - configFileName = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - runErr := app.Run(args, env, file, configFileName, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, runErr, testcase.wantError) - testutil.AssertStringContains(t, out.String(), testcase.wantOutput) - - if testcase.manifest != "" { - m := filepath.Join(rootdir, manifest.Filename) - b, err := os.ReadFile(m) - if err != nil { - t.Fatal(err) - } - - if testcase.expectEmptyServiceID { - testutil.AssertStringContains(t, string(b), `service_id = ""`) - } else if !nonEmptyServiceID.Match(b) && runErr == nil { - // The runErr check is to prevent the first test case from causing an - // accidental failure. As the fastly.toml doesn't have a service_id - // set, while marshalling back and forth it'll get converted to an - // empty string in the manifest file which will accidentally trigger - // the following test error otherwise if we don't check for the nil - // error value. Because that first test case expects an error to be - // raised we know that we can safely check for `runErr == nil` here. - t.Fatal("expected service_id to contain a value") - } - } - }) - } -} - -func makeTempEnvironment(t *testing.T, fixture string) (rootdir string) { - t.Helper() - - rootdir, err := os.MkdirTemp("", "fastly-temp-*") - if err != nil { - t.Fatal(err) - } - - if err := os.MkdirAll(rootdir, 0700); err != nil { - t.Fatal(err) - } - - if fixture != "" { - path, err := filepath.Abs(filepath.Join("testdata", fixture)) - if err != nil { - t.Fatal(err) - } - - b, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - - filename := filepath.Join(rootdir, manifest.Filename) - if err := os.WriteFile(filename, b, 0777); err != nil { - t.Fatal(err) - } - } - - return rootdir -} - -var errTest = errors.New("fixture error") - -func createServiceOK(i *fastly.CreateServiceInput) (*fastly.Service, error) { - return &fastly.Service{ - ID: "12345", - Name: i.Name, - Type: i.Type, - Comment: i.Comment, - }, nil -} - -func createServiceError(*fastly.CreateServiceInput) (*fastly.Service, error) { - return nil, errTest -} - -func listServicesOK(i *fastly.ListServicesInput) ([]*fastly.Service, error) { - return []*fastly.Service{ - { - ID: "123", - Name: "Foo", - Type: "wasm", - CustomerID: "mycustomerid", - ActiveVersion: 2, - UpdatedAt: testutil.MustParseTimeRFC3339("2010-11-15T19:01:02Z"), - Versions: []*fastly.Version{ - { - Number: 1, - Comment: "a", - ServiceID: "b", - CreatedAt: testutil.MustParseTimeRFC3339("2001-02-03T04:05:06Z"), - UpdatedAt: testutil.MustParseTimeRFC3339("2001-02-04T04:05:06Z"), - DeletedAt: testutil.MustParseTimeRFC3339("2001-02-05T04:05:06Z"), - }, - { - Number: 2, - Comment: "c", - ServiceID: "d", - Active: true, - Deployed: true, - CreatedAt: testutil.MustParseTimeRFC3339("2001-03-03T04:05:06Z"), - UpdatedAt: testutil.MustParseTimeRFC3339("2001-03-04T04:05:06Z"), - }, - }, - }, - { - ID: "456", - Name: "Bar", - Type: "wasm", - CustomerID: "mycustomerid", - ActiveVersion: 1, - UpdatedAt: testutil.MustParseTimeRFC3339("2015-03-14T12:59:59Z"), - }, - { - ID: "789", - Name: "Baz", - Type: "", - CustomerID: "mycustomerid", - ActiveVersion: 1, - // nil UpdatedAt - }, - }, nil -} - -func listServicesError(i *fastly.ListServicesInput) ([]*fastly.Service, error) { - return nil, errTest -} - -var listServicesShortOutput = strings.TrimSpace(` -NAME ID TYPE ACTIVE VERSION LAST EDITED (UTC) -Foo 123 wasm 2 2010-11-15 19:01 -Bar 456 wasm 1 2015-03-14 12:59 -Baz 789 vcl 1 n/a -`) + "\n" - -var listServicesVerboseOutput = strings.TrimSpace(` -Fastly API token not provided -Fastly API endpoint: https://api.fastly.com -Service 1/3 - ID: 123 - Name: Foo - Type: wasm - Customer ID: mycustomerid - Last edited (UTC): 2010-11-15 19:01 - Active version: 2 - Versions: 2 - Version 1/2 - Number: 1 - Comment: a - Service ID: b - Active: false - Locked: false - Deployed: false - Staging: false - Testing: false - Created (UTC): 2001-02-03 04:05 - Last edited (UTC): 2001-02-04 04:05 - Deleted (UTC): 2001-02-05 04:05 - Version 2/2 - Number: 2 - Comment: c - Service ID: d - Active: true - Locked: false - Deployed: true - Staging: false - Testing: false - Created (UTC): 2001-03-03 04:05 - Last edited (UTC): 2001-03-04 04:05 - -Service 2/3 - ID: 456 - Name: Bar - Type: wasm - Customer ID: mycustomerid - Last edited (UTC): 2015-03-14 12:59 - Active version: 1 - Versions: 0 - -Service 3/3 - ID: 789 - Name: Baz - Type: vcl - Customer ID: mycustomerid - Active version: 1 - Versions: 0 -`) + "\n\n" - -func getServiceOK(i *fastly.GetServiceInput) (*fastly.Service, error) { - return &fastly.Service{ - ID: "12345", - Name: "Foo", - Comment: "Bar", - }, nil -} - -func describeServiceOK(i *fastly.GetServiceInput) (*fastly.ServiceDetail, error) { - return &fastly.ServiceDetail{ - ID: "123", - Name: "Foo", - Type: "wasm", - CustomerID: "mycustomerid", - ActiveVersion: fastly.Version{ - Number: 2, - Comment: "c", - ServiceID: "d", - Active: true, - Deployed: true, - CreatedAt: testutil.MustParseTimeRFC3339("2001-03-03T04:05:06Z"), - UpdatedAt: testutil.MustParseTimeRFC3339("2001-03-04T04:05:06Z"), - }, - UpdatedAt: testutil.MustParseTimeRFC3339("2010-11-15T19:01:02Z"), - Versions: []*fastly.Version{ - { - Number: 1, - Comment: "a", - ServiceID: "b", - CreatedAt: testutil.MustParseTimeRFC3339("2001-02-03T04:05:06Z"), - UpdatedAt: testutil.MustParseTimeRFC3339("2001-02-04T04:05:06Z"), - DeletedAt: testutil.MustParseTimeRFC3339("2001-02-05T04:05:06Z"), - }, - { - Number: 2, - Comment: "c", - ServiceID: "d", - Active: true, - Deployed: true, - CreatedAt: testutil.MustParseTimeRFC3339("2001-03-03T04:05:06Z"), - UpdatedAt: testutil.MustParseTimeRFC3339("2001-03-04T04:05:06Z"), - }, - }, - }, nil -} - -func describeServiceError(i *fastly.GetServiceInput) (*fastly.ServiceDetail, error) { - return nil, errTest -} - -var describeServiceShortOutput = strings.TrimSpace(` -ID: 123 -Name: Foo -Type: wasm -Customer ID: mycustomerid -Last edited (UTC): 2010-11-15 19:01 -Active version: - Number: 2 - Comment: c - Service ID: d - Active: true - Locked: false - Deployed: true - Staging: false - Testing: false - Created (UTC): 2001-03-03 04:05 - Last edited (UTC): 2001-03-04 04:05 -Versions: 2 - Version 1/2 - Number: 1 - Comment: a - Service ID: b - Active: false - Locked: false - Deployed: false - Staging: false - Testing: false - Created (UTC): 2001-02-03 04:05 - Last edited (UTC): 2001-02-04 04:05 - Deleted (UTC): 2001-02-05 04:05 - Version 2/2 - Number: 2 - Comment: c - Service ID: d - Active: true - Locked: false - Deployed: true - Staging: false - Testing: false - Created (UTC): 2001-03-03 04:05 - Last edited (UTC): 2001-03-04 04:05 -`) + "\n" - -var describeServiceVerboseOutput = strings.TrimSpace(` -Fastly API token not provided -Fastly API endpoint: https://api.fastly.com -ID: 123 -Name: Foo -Type: wasm -Customer ID: mycustomerid -Last edited (UTC): 2010-11-15 19:01 -Active version: - Number: 2 - Comment: c - Service ID: d - Active: true - Locked: false - Deployed: true - Staging: false - Testing: false - Created (UTC): 2001-03-03 04:05 - Last edited (UTC): 2001-03-04 04:05 -Versions: 2 - Version 1/2 - Number: 1 - Comment: a - Service ID: b - Active: false - Locked: false - Deployed: false - Staging: false - Testing: false - Created (UTC): 2001-02-03 04:05 - Last edited (UTC): 2001-02-04 04:05 - Deleted (UTC): 2001-02-05 04:05 - Version 2/2 - Number: 2 - Comment: c - Service ID: d - Active: true - Locked: false - Deployed: true - Staging: false - Testing: false - Created (UTC): 2001-03-03 04:05 - Last edited (UTC): 2001-03-04 04:05 -`) + "\n" - -func searchServiceOK(i *fastly.SearchServiceInput) (*fastly.Service, error) { - return &fastly.Service{ - ID: "123", - Name: "Foo", - Type: "wasm", - CustomerID: "mycustomerid", - UpdatedAt: testutil.MustParseTimeRFC3339("2010-11-15T19:01:02Z"), - Versions: []*fastly.Version{ - { - Number: 1, - Comment: "a", - ServiceID: "b", - CreatedAt: testutil.MustParseTimeRFC3339("2001-02-03T04:05:06Z"), - UpdatedAt: testutil.MustParseTimeRFC3339("2001-02-04T04:05:06Z"), - DeletedAt: testutil.MustParseTimeRFC3339("2001-02-05T04:05:06Z"), - }, - { - Number: 2, - Comment: "c", - ServiceID: "d", - Active: true, - Deployed: true, - CreatedAt: testutil.MustParseTimeRFC3339("2001-03-03T04:05:06Z"), - UpdatedAt: testutil.MustParseTimeRFC3339("2001-03-04T04:05:06Z"), - }, - }, - }, nil -} - -var searchServiceShortOutput = strings.TrimSpace(` -ID: 123 -Name: Foo -Type: wasm -Customer ID: mycustomerid -Last edited (UTC): 2010-11-15 19:01 -Active version: 0 -Versions: 2 - Version 1/2 - Number: 1 - Comment: a - Service ID: b - Active: false - Locked: false - Deployed: false - Staging: false - Testing: false - Created (UTC): 2001-02-03 04:05 - Last edited (UTC): 2001-02-04 04:05 - Deleted (UTC): 2001-02-05 04:05 - Version 2/2 - Number: 2 - Comment: c - Service ID: d - Active: true - Locked: false - Deployed: true - Staging: false - Testing: false - Created (UTC): 2001-03-03 04:05 - Last edited (UTC): 2001-03-04 04:05 -`) + "\n" - -var searchServiceVerboseOutput = strings.TrimSpace(` -Fastly API token not provided -Fastly API endpoint: https://api.fastly.com -ID: 123 -Name: Foo -Type: wasm -Customer ID: mycustomerid -Last edited (UTC): 2010-11-15 19:01 -Active version: 0 -Versions: 2 - Version 1/2 - Number: 1 - Comment: a - Service ID: b - Active: false - Locked: false - Deployed: false - Staging: false - Testing: false - Created (UTC): 2001-02-03 04:05 - Last edited (UTC): 2001-02-04 04:05 - Deleted (UTC): 2001-02-05 04:05 - Version 2/2 - Number: 2 - Comment: c - Service ID: d - Active: true - Locked: false - Deployed: true - Staging: false - Testing: false - Created (UTC): 2001-03-03 04:05 - Last edited (UTC): 2001-03-04 04:05 -`) + "\n" - -func updateServiceOK(i *fastly.UpdateServiceInput) (*fastly.Service, error) { - return &fastly.Service{ - ID: "12345", - }, nil -} - -func updateServiceError(*fastly.UpdateServiceInput) (*fastly.Service, error) { - return nil, errTest -} - -func deleteServiceOK(*fastly.DeleteServiceInput) error { - return nil -} - -func deleteServiceError(*fastly.DeleteServiceInput) error { - return errTest -} diff --git a/pkg/service/testdata/fastly-no-serviceid.toml b/pkg/service/testdata/fastly-no-serviceid.toml deleted file mode 100644 index f51c22641..000000000 --- a/pkg/service/testdata/fastly-no-serviceid.toml +++ /dev/null @@ -1,5 +0,0 @@ -manifest_version = 1 -name = "Default Rust template" -description = "Default package template for Rust based edge compute projects." -authors = ["phamann "] -language = "rust" diff --git a/pkg/service/testdata/fastly-valid.toml b/pkg/service/testdata/fastly-valid.toml deleted file mode 100644 index 99addd521..000000000 --- a/pkg/service/testdata/fastly-valid.toml +++ /dev/null @@ -1,6 +0,0 @@ -manifest_version = 1 -name = "Default Rust template" -description = "Default package template for Rust based edge compute projects." -authors = ["phamann "] -language = "rust" -service_id = "123" diff --git a/pkg/service/update.go b/pkg/service/update.go deleted file mode 100644 index 64ada1b7e..000000000 --- a/pkg/service/update.go +++ /dev/null @@ -1,77 +0,0 @@ -package service - -import ( - "fmt" - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// UpdateCommand calls the Fastly API to create services. -type UpdateCommand struct { - common.Base - manifest manifest.Data - input fastly.UpdateServiceInput - - // TODO(integralist): - // Ensure consistency in capitalization, should be lowercase to avoid - // ambiguity in common.Command interface. - // - name common.OptionalString - comment common.OptionalString -} - -// NewUpdateCommand returns a usable command registered under the parent. -func NewUpdateCommand(parent common.Registerer, globals *config.Data) *UpdateCommand { - var c UpdateCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("update", "Update a Fastly service") - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("name", "Service name").Short('n').Action(c.name.Set).StringVar(&c.name.Value) - c.CmdClause.Flag("comment", "Human-readable comment").Action(c.comment.Set).StringVar(&c.comment.Value) - return &c -} - -// Exec invokes the application logic for the command. -func (c *UpdateCommand) Exec(in io.Reader, out io.Writer) error { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - c.input.ServiceID = serviceID - - // TODO(integralist): - // Validation such as this should become redundant once Go-Fastly is - // consistently implementing validation (which itself should be redundant - // once each backend API is 100% confirmed as validating client inputs). - // - // As it stands we have multiple clients duplicating logic which should exist - // (and thus be relied upon) at the API layer. - // - // If neither arguments are provided, error with useful message. - if !c.name.WasSet && !c.comment.WasSet { - return fmt.Errorf("error parsing arguments: must provide either --name or --comment to update service") - } - - if c.name.WasSet { - c.input.Name = fastly.String(c.name.Value) - } - if c.comment.WasSet { - c.input.Comment = fastly.String(c.comment.Value) - } - - s, err := c.Globals.Client.UpdateService(&c.input) - if err != nil { - return err - } - - text.Success(out, "Updated service %s", s.ID) - return nil -} diff --git a/pkg/serviceversion/activate.go b/pkg/serviceversion/activate.go deleted file mode 100644 index 39c474850..000000000 --- a/pkg/serviceversion/activate.go +++ /dev/null @@ -1,48 +0,0 @@ -package serviceversion - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// ActivateCommand calls the Fastly API to activate a service version. -type ActivateCommand struct { - common.Base - manifest manifest.Data - Input fastly.ActivateVersionInput -} - -// NewActivateCommand returns a usable command registered under the parent. -func NewActivateCommand(parent common.Registerer, globals *config.Data) *ActivateCommand { - var c ActivateCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("activate", "Activate a Fastly service version") - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("version", "Number of version you wish to activate").Required().IntVar(&c.Input.ServiceVersion) - return &c -} - -// Exec invokes the application logic for the command. -func (c *ActivateCommand) Exec(in io.Reader, out io.Writer) error { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - c.Input.ServiceID = serviceID - - v, err := c.Globals.Client.ActivateVersion(&c.Input) - if err != nil { - return err - } - - text.Success(out, "Activated service %s version %d", v.ServiceID, c.Input.ServiceVersion) - return nil -} diff --git a/pkg/serviceversion/clone.go b/pkg/serviceversion/clone.go deleted file mode 100644 index 42e83a609..000000000 --- a/pkg/serviceversion/clone.go +++ /dev/null @@ -1,48 +0,0 @@ -package serviceversion - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// CloneCommand calls the Fastly API to clone a service version. -type CloneCommand struct { - common.Base - manifest manifest.Data - Input fastly.CloneVersionInput -} - -// NewCloneCommand returns a usable command registered under the parent. -func NewCloneCommand(parent common.Registerer, globals *config.Data) *CloneCommand { - var c CloneCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("clone", "Clone a Fastly service version") - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("version", "Number of version you wish to clone").Required().IntVar(&c.Input.ServiceVersion) - return &c -} - -// Exec invokes the application logic for the command. -func (c *CloneCommand) Exec(in io.Reader, out io.Writer) error { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - c.Input.ServiceID = serviceID - - v, err := c.Globals.Client.CloneVersion(&c.Input) - if err != nil { - return err - } - - text.Success(out, "Cloned service %s version %d to version %d", v.ServiceID, c.Input.ServiceVersion, v.Number) - return nil -} diff --git a/pkg/serviceversion/deactivate.go b/pkg/serviceversion/deactivate.go deleted file mode 100644 index 5c0276dda..000000000 --- a/pkg/serviceversion/deactivate.go +++ /dev/null @@ -1,48 +0,0 @@ -package serviceversion - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// DeactivateCommand calls the Fastly API to deactivate a service version. -type DeactivateCommand struct { - common.Base - manifest manifest.Data - Input fastly.DeactivateVersionInput -} - -// NewDeactivateCommand returns a usable command registered under the parent. -func NewDeactivateCommand(parent common.Registerer, globals *config.Data) *DeactivateCommand { - var c DeactivateCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("deactivate", "Deactivate a Fastly service version") - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("version", "Number of version you wish to deactivate").Required().IntVar(&c.Input.ServiceVersion) - return &c -} - -// Exec invokes the application logic for the command. -func (c *DeactivateCommand) Exec(in io.Reader, out io.Writer) error { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - c.Input.ServiceID = serviceID - - v, err := c.Globals.Client.DeactivateVersion(&c.Input) - if err != nil { - return err - } - - text.Success(out, "Deactivated service %s version %d", v.ServiceID, c.Input.ServiceVersion) - return nil -} diff --git a/pkg/serviceversion/list.go b/pkg/serviceversion/list.go deleted file mode 100644 index 6ae36de7e..000000000 --- a/pkg/serviceversion/list.go +++ /dev/null @@ -1,64 +0,0 @@ -package serviceversion - -import ( - "fmt" - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// ListCommand calls the Fastly API to list services. -type ListCommand struct { - common.Base - manifest manifest.Data - Input fastly.ListVersionsInput -} - -// NewListCommand returns a usable command registered under the parent. -func NewListCommand(parent common.Registerer, globals *config.Data) *ListCommand { - var c ListCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("list", "List Fastly service versions") - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - return &c -} - -// Exec invokes the application logic for the command. -func (c *ListCommand) Exec(in io.Reader, out io.Writer) error { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - c.Input.ServiceID = serviceID - - versions, err := c.Globals.Client.ListVersions(&c.Input) - if err != nil { - return err - } - - if !c.Globals.Verbose() { - tw := text.NewTable(out) - tw.AddHeader("NUMBER", "ACTIVE", "LAST EDITED (UTC)") - for _, version := range versions { - tw.AddLine(version.Number, version.Active, version.UpdatedAt.UTC().Format(common.TimeFormat)) - } - tw.Print() - return nil - } - - fmt.Fprintf(out, "Versions: %d\n", len(versions)) - for i, version := range versions { - fmt.Fprintf(out, "\tVersion %d/%d\n", i+1, len(versions)) - text.PrintVersion(out, "\t\t", version) - } - fmt.Fprintln(out) - - return nil -} diff --git a/pkg/serviceversion/lock.go b/pkg/serviceversion/lock.go deleted file mode 100644 index 1a373b0d0..000000000 --- a/pkg/serviceversion/lock.go +++ /dev/null @@ -1,48 +0,0 @@ -package serviceversion - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// LockCommand calls the Fastly API to lock a service version. -type LockCommand struct { - common.Base - manifest manifest.Data - Input fastly.LockVersionInput -} - -// NewLockCommand returns a usable command registered under the parent. -func NewLockCommand(parent common.Registerer, globals *config.Data) *LockCommand { - var c LockCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("lock", "Lock a Fastly service version") - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("version", "Number of version you wish to lock").Required().IntVar(&c.Input.ServiceVersion) - return &c -} - -// Exec invokes the application logic for the command. -func (c *LockCommand) Exec(in io.Reader, out io.Writer) error { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - c.Input.ServiceID = serviceID - - v, err := c.Globals.Client.LockVersion(&c.Input) - if err != nil { - return err - } - - text.Success(out, "Locked service %s version %d", v.ServiceID, c.Input.ServiceVersion) - return nil -} diff --git a/pkg/serviceversion/root.go b/pkg/serviceversion/root.go deleted file mode 100644 index a07ccf4fb..000000000 --- a/pkg/serviceversion/root.go +++ /dev/null @@ -1,28 +0,0 @@ -package serviceversion - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/config" -) - -// RootCommand is the parent command for all subcommands in this package. -// It should be installed under the primary root command. -type RootCommand struct { - common.Base - // no flags -} - -// NewRootCommand returns a new command registered in the parent. -func NewRootCommand(parent common.Registerer, globals *config.Data) *RootCommand { - var c RootCommand - c.Globals = globals - c.CmdClause = parent.Command("service-version", "Manipulate Fastly service versions") - return &c -} - -// Exec implements the command interface. -func (c *RootCommand) Exec(in io.Reader, out io.Writer) error { - panic("unreachable") -} diff --git a/pkg/serviceversion/serviceversion_test.go b/pkg/serviceversion/serviceversion_test.go deleted file mode 100644 index 10c5764ff..000000000 --- a/pkg/serviceversion/serviceversion_test.go +++ /dev/null @@ -1,425 +0,0 @@ -package serviceversion_test - -import ( - "bytes" - "errors" - "io" - "net/http" - "strings" - "testing" - - "github.com/fastly/cli/pkg/app" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/mock" - "github.com/fastly/cli/pkg/testutil" - "github.com/fastly/cli/pkg/update" - "github.com/fastly/go-fastly/v3/fastly" -) - -func TestVersionClone(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"service-version", "clone", "--version", "1"}, - api: mock.API{CloneVersionFn: cloneVersionOK}, - wantError: "error reading service: no service ID found", - }, - { - args: []string{"service-version", "clone", "--service-id", "123"}, - api: mock.API{CloneVersionFn: cloneVersionOK}, - wantError: "error parsing arguments: required flag --version not provided", - }, - { - args: []string{"service-version", "clone", "--service-id", "123", "--version", "1"}, - api: mock.API{CloneVersionFn: cloneVersionOK}, - wantOutput: "Cloned service 123 version 1 to version 2", - }, - { - args: []string{"service-version", "clone", "--service-id", "123", "--version", "1"}, - api: mock.API{CloneVersionFn: cloneVersionError}, - wantError: errTest.Error(), - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertStringContains(t, out.String(), testcase.wantOutput) - }) - } -} - -func TestVersionList(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"service-version", "list", "--service-id", "123"}, - api: mock.API{ListVersionsFn: listVersionsOK}, - wantOutput: listVersionsShortOutput, - }, - { - args: []string{"service-version", "list", "--service-id", "123", "--verbose"}, - api: mock.API{ListVersionsFn: listVersionsOK}, - wantOutput: listVersionsVerboseOutput, - }, - { - args: []string{"service-version", "list", "--service-id", "123", "-v"}, - api: mock.API{ListVersionsFn: listVersionsOK}, - wantOutput: listVersionsVerboseOutput, - }, - { - args: []string{"service-version", "--verbose", "list", "--service-id", "123"}, - api: mock.API{ListVersionsFn: listVersionsOK}, - wantOutput: listVersionsVerboseOutput, - }, - { - args: []string{"-v", "service-version", "list", "--service-id", "123"}, - api: mock.API{ListVersionsFn: listVersionsOK}, - wantOutput: listVersionsVerboseOutput, - }, - { - args: []string{"service-version", "list", "--service-id", "123"}, - api: mock.API{ListVersionsFn: listVersionsError}, - wantError: errTest.Error(), - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertString(t, testcase.wantOutput, out.String()) - }) - } -} - -func TestVersionUpdate(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"service-version", "update", "--service-id", "123", "--version", "1", "--comment", "foo"}, - api: mock.API{UpdateVersionFn: updateVersionOK}, - wantOutput: "Updated service 123 version 1", - }, - { - args: []string{"service-version", "update", "--service-id", "123", "--version", "2"}, - api: mock.API{UpdateVersionFn: updateVersionOK}, - wantError: "error parsing arguments: required flag --comment not provided", - }, - { - args: []string{"service-version", "update", "--service-id", "123", "--version", "1", "--comment", "foo"}, - api: mock.API{UpdateVersionFn: updateVersionError}, - wantError: errTest.Error(), - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertStringContains(t, out.String(), testcase.wantOutput) - }) - } -} - -func TestVersionActivate(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"service-version", "activate", "--service-id", "123"}, - api: mock.API{ActivateVersionFn: activateVersionOK}, - wantError: "error parsing arguments: required flag --version not provided", - }, - { - args: []string{"service-version", "activate", "--service-id", "123", "--version", "1"}, - api: mock.API{ActivateVersionFn: activateVersionOK}, - wantOutput: "Activated service 123 version 1", - }, - { - args: []string{"service-version", "activate", "--service-id", "123", "--version", "1"}, - api: mock.API{ActivateVersionFn: activateVersionError}, - wantError: errTest.Error(), - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertStringContains(t, out.String(), testcase.wantOutput) - }) - } -} - -func TestVersionDeactivate(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"service-version", "deactivate", "--service-id", "123"}, - api: mock.API{DeactivateVersionFn: deactivateVersionOK}, - wantError: "error parsing arguments: required flag --version not provided", - }, - { - args: []string{"service-version", "deactivate", "--service-id", "123", "--version", "1"}, - api: mock.API{DeactivateVersionFn: deactivateVersionOK}, - wantOutput: "Deactivated service 123 version 1", - }, - { - args: []string{"service-version", "deactivate", "--service-id", "123", "--version", "1"}, - api: mock.API{DeactivateVersionFn: deactivateVersionError}, - wantError: errTest.Error(), - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertStringContains(t, out.String(), testcase.wantOutput) - }) - } -} - -func TestVersionLock(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"service-version", "lock", "--service-id", "123"}, - api: mock.API{LockVersionFn: lockVersionOK}, - wantError: "error parsing arguments: required flag --version not provided", - }, - { - args: []string{"service-version", "lock", "--service-id", "123", "--version", "1"}, - api: mock.API{LockVersionFn: lockVersionOK}, - wantOutput: "Locked service 123 version 1", - }, - { - args: []string{"service-version", "lock", "--service-id", "123", "--version", "1"}, - api: mock.API{LockVersionFn: lockVersionError}, - wantError: errTest.Error(), - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - appConfigFile = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, appConfigFile, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertStringContains(t, out.String(), testcase.wantOutput) - }) - } -} - -var errTest = errors.New("fixture error") - -func cloneVersionOK(i *fastly.CloneVersionInput) (*fastly.Version, error) { - return &fastly.Version{ - Number: i.ServiceVersion + 1, - ServiceID: "123", - Active: true, - Deployed: true, - CreatedAt: testutil.MustParseTimeRFC3339("2010-11-15T19:01:02Z"), - }, nil -} - -func cloneVersionError(i *fastly.CloneVersionInput) (*fastly.Version, error) { - return nil, errTest -} - -func listVersionsOK(i *fastly.ListVersionsInput) ([]*fastly.Version, error) { - return []*fastly.Version{ - { - Number: 1, - Comment: "a", - ServiceID: "b", - CreatedAt: testutil.MustParseTimeRFC3339("2001-02-03T04:05:06Z"), - UpdatedAt: testutil.MustParseTimeRFC3339("2010-11-15T19:01:02Z"), - }, - { - Number: 2, - Comment: "c", - ServiceID: "b", - Active: true, - Deployed: true, - CreatedAt: testutil.MustParseTimeRFC3339("2001-03-03T04:05:06Z"), - UpdatedAt: testutil.MustParseTimeRFC3339("2015-03-14T12:59:59Z"), - }, - }, nil -} - -func listVersionsError(i *fastly.ListVersionsInput) ([]*fastly.Version, error) { - return nil, errTest -} - -var listVersionsShortOutput = strings.TrimSpace(` -NUMBER ACTIVE LAST EDITED (UTC) -1 false 2010-11-15 19:01 -2 true 2015-03-14 12:59 -`) + "\n" - -var listVersionsVerboseOutput = strings.TrimSpace(` -Fastly API token not provided -Fastly API endpoint: https://api.fastly.com -Versions: 2 - Version 1/2 - Number: 1 - Comment: a - Service ID: b - Active: false - Locked: false - Deployed: false - Staging: false - Testing: false - Created (UTC): 2001-02-03 04:05 - Last edited (UTC): 2010-11-15 19:01 - Version 2/2 - Number: 2 - Comment: c - Service ID: b - Active: true - Locked: false - Deployed: true - Staging: false - Testing: false - Created (UTC): 2001-03-03 04:05 - Last edited (UTC): 2015-03-14 12:59 -`) + "\n\n" - -func updateVersionOK(i *fastly.UpdateVersionInput) (*fastly.Version, error) { - return &fastly.Version{ - Number: i.ServiceVersion, - ServiceID: "123", - Active: true, - Deployed: true, - Comment: "foo", - CreatedAt: testutil.MustParseTimeRFC3339("2010-11-15T19:01:02Z"), - }, nil -} - -func updateVersionError(i *fastly.UpdateVersionInput) (*fastly.Version, error) { - return nil, errTest -} - -func activateVersionOK(i *fastly.ActivateVersionInput) (*fastly.Version, error) { - return &fastly.Version{ - Number: i.ServiceVersion, - ServiceID: "123", - Active: true, - Deployed: true, - CreatedAt: testutil.MustParseTimeRFC3339("2010-11-15T19:01:02Z"), - UpdatedAt: testutil.MustParseTimeRFC3339("2010-11-15T19:01:02Z"), - }, nil -} - -func activateVersionError(i *fastly.ActivateVersionInput) (*fastly.Version, error) { - return nil, errTest -} - -func deactivateVersionOK(i *fastly.DeactivateVersionInput) (*fastly.Version, error) { - return &fastly.Version{ - Number: i.ServiceVersion, - ServiceID: "123", - Active: false, - Deployed: true, - CreatedAt: testutil.MustParseTimeRFC3339("2010-11-15T19:01:02Z"), - UpdatedAt: testutil.MustParseTimeRFC3339("2010-11-15T19:01:02Z"), - }, nil -} - -func deactivateVersionError(i *fastly.DeactivateVersionInput) (*fastly.Version, error) { - return nil, errTest -} - -func lockVersionOK(i *fastly.LockVersionInput) (*fastly.Version, error) { - return &fastly.Version{ - Number: i.ServiceVersion, - ServiceID: "123", - Active: false, - Deployed: true, - Locked: true, - CreatedAt: testutil.MustParseTimeRFC3339("2010-11-15T19:01:02Z"), - UpdatedAt: testutil.MustParseTimeRFC3339("2010-11-15T19:01:02Z"), - }, nil -} - -func lockVersionError(i *fastly.LockVersionInput) (*fastly.Version, error) { - return nil, errTest -} diff --git a/pkg/serviceversion/update.go b/pkg/serviceversion/update.go deleted file mode 100644 index d6a5d7929..000000000 --- a/pkg/serviceversion/update.go +++ /dev/null @@ -1,67 +0,0 @@ -package serviceversion - -import ( - "fmt" - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// UpdateCommand calls the Fastly API to update a service version. -type UpdateCommand struct { - common.Base - manifest manifest.Data - input fastly.UpdateVersionInput - - comment common.OptionalString -} - -// NewUpdateCommand returns a usable command registered under the parent. -func NewUpdateCommand(parent common.Registerer, globals *config.Data) *UpdateCommand { - var c UpdateCommand - c.Globals = globals - c.manifest.File.SetOutput(c.Globals.Output) - c.manifest.File.Read(manifest.Filename) - c.CmdClause = parent.Command("update", "Update a Fastly service version") - c.CmdClause.Flag("service-id", "Service ID").Short('s').StringVar(&c.manifest.Flag.ServiceID) - c.CmdClause.Flag("version", "Number of version you wish to update").Required().IntVar(&c.input.ServiceVersion) - - // TODO(integralist): - // Make 'comment' field mandatory once we roll out a new release of Go-Fastly - // which will hopefully have better/more correct consistency as far as which - // fields are supposed to be optional and which should be 'required'. - // - c.CmdClause.Flag("comment", "Human-readable comment").Action(c.comment.Set).StringVar(&c.comment.Value) - return &c -} - -// Exec invokes the application logic for the command. -func (c *UpdateCommand) Exec(in io.Reader, out io.Writer) error { - serviceID, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - - c.input.ServiceID = serviceID - - if !c.comment.WasSet { - return fmt.Errorf("error parsing arguments: required flag --comment not provided") - } - - if c.comment.WasSet { - c.input.Comment = fastly.String(c.comment.Value) - } - - v, err := c.Globals.Client.UpdateVersion(&c.input) - if err != nil { - return err - } - - text.Success(out, "Updated service %s version %d", v.ServiceID, c.input.ServiceVersion) - return nil -} diff --git a/pkg/stats/historical.go b/pkg/stats/historical.go deleted file mode 100644 index a15ef4000..000000000 --- a/pkg/stats/historical.go +++ /dev/null @@ -1,100 +0,0 @@ -package stats - -import ( - "encoding/json" - "fmt" - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/go-fastly/v3/fastly" -) - -const statusSuccess = "success" - -// HistoricalCommand exposes the Historical Stats API. -type HistoricalCommand struct { - common.Base - manifest manifest.Data - - Input fastly.GetStatsInput - formatFlag string -} - -// NewHistoricalCommand is the "stats historical" subcommand. -func NewHistoricalCommand(parent common.Registerer, globals *config.Data) *HistoricalCommand { - var c HistoricalCommand - c.Globals = globals - - c.CmdClause = parent.Command("historical", "View historical stats for a Fastly service") - c.CmdClause.Flag("service-id", "Service ID").Short('s').Required().StringVar(&c.manifest.Flag.ServiceID) - - c.CmdClause.Flag("from", "From time, accepted formats at https://docs.fastly.com/api/stats#Range").StringVar(&c.Input.From) - c.CmdClause.Flag("to", "To time").StringVar(&c.Input.To) - c.CmdClause.Flag("by", "Aggregation period (minute/hour/day)").EnumVar(&c.Input.By, "minute", "hour", "day") - c.CmdClause.Flag("region", "Filter by region ('stats regions' to list)").StringVar(&c.Input.Region) - - c.CmdClause.Flag("format", "Output format (json)").EnumVar(&c.formatFlag, "json") - - return &c -} - -// Exec implements the command interface. -func (c *HistoricalCommand) Exec(in io.Reader, out io.Writer) error { - service, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - c.Input.Service = service - - var envelope statsResponse - err := c.Globals.Client.GetStatsJSON(&c.Input, &envelope) - if err != nil { - return err - } - - if envelope.Status != statusSuccess { - return fmt.Errorf("non-success response: %s", envelope.Msg) - } - - switch c.formatFlag { - case "json": - writeBlocksJSON(out, service, envelope.Data) - - default: - writeHeader(out, envelope.Meta) - writeBlocks(out, service, envelope.Data) - } - - return nil -} - -func writeHeader(out io.Writer, meta statsResponseMeta) { - fmt.Fprintf(out, "From: %s\n", meta.From) - fmt.Fprintf(out, "To: %s\n", meta.To) - fmt.Fprintf(out, "By: %s\n", meta.By) - fmt.Fprintf(out, "Region: %s\n", meta.Region) - fmt.Fprintf(out, "---\n") -} - -func writeBlocks(out io.Writer, service string, blocks []statsResponseData) error { - for _, block := range blocks { - if err := fmtBlock(out, service, block); err != nil { - return err - } - } - - return nil -} - -func writeBlocksJSON(out io.Writer, service string, blocks []statsResponseData) error { - for _, block := range blocks { - if err := json.NewEncoder(out).Encode(block); err != nil { - return err - } - } - - return nil -} diff --git a/pkg/stats/historical_test.go b/pkg/stats/historical_test.go deleted file mode 100644 index 6c04b7d7b..000000000 --- a/pkg/stats/historical_test.go +++ /dev/null @@ -1,112 +0,0 @@ -package stats_test - -import ( - "bytes" - "encoding/json" - "io" - "net/http" - "strings" - "testing" - - "github.com/fastly/cli/pkg/app" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/mock" - "github.com/fastly/cli/pkg/testutil" - "github.com/fastly/cli/pkg/update" - "github.com/fastly/go-fastly/v3/fastly" -) - -func TestHistorical(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"stats", "historical", "--service-id=123"}, - api: mock.API{GetStatsJSONFn: getStatsJSONOK}, - wantOutput: historicalOK, - }, - { - args: []string{"stats", "historical", "--service-id=123"}, - api: mock.API{GetStatsJSONFn: getStatsJSONError}, - wantError: errTest.Error(), - }, - { - args: []string{"stats", "historical", "--service-id=123", "--format=json"}, - api: mock.API{GetStatsJSONFn: getStatsJSONOK}, - wantOutput: historicalJSONOK, - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - configFileName = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, configFileName, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertStringContains(t, out.String(), testcase.wantOutput) - }) - } -} - -var historicalOK = `From: Wed May 15 20:08:35 UTC 2013 -To: Thu May 16 20:08:35 UTC 2013 -By: day -Region: all ---- -Service ID: 123 -Start Time: 1970-01-01 00:00:00 +0000 UTC --------------------------------------------------- -Hit Rate: 0.00% -Avg Hit Time: 0.00µs -Avg Miss Time: 0.00µs - -Request BW: 0 - Headers: 0 - Body: 0 - -Response BW: 0 - Headers: 0 - Body: 0 - -Requests: 0 - Hit: 0 - Miss: 0 - Pass: 0 - Synth: 0 - Error: 0 - Uncacheable: 0 -` - -var historicalJSONOK = `{"start_time":0} -` - -func getStatsJSONOK(i *fastly.GetStatsInput, o interface{}) error { - msg := []byte(` -{ - "status": "success", - "meta": { - "to": "Thu May 16 20:08:35 UTC 2013", - "from": "Wed May 15 20:08:35 UTC 2013", - "by": "day", - "region": "all" - }, - "msg": null, - "data": [{"start_time": 0}] -}`) - - return json.Unmarshal(msg, o) -} - -func getStatsJSONError(i *fastly.GetStatsInput, o interface{}) error { - return errTest -} diff --git a/pkg/stats/realtime.go b/pkg/stats/realtime.go deleted file mode 100644 index d9ea29545..000000000 --- a/pkg/stats/realtime.go +++ /dev/null @@ -1,114 +0,0 @@ -package stats - -import ( - "encoding/json" - "io" - - "github.com/fastly/cli/pkg/api" - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/compute/manifest" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" -) - -// RealtimeCommand exposes the Realtime Metrics API. -type RealtimeCommand struct { - common.Base - manifest manifest.Data - - formatFlag string -} - -// NewRealtimeCommand is the "stats realtime" subcommand. -func NewRealtimeCommand(parent common.Registerer, globals *config.Data) *RealtimeCommand { - var c RealtimeCommand - c.Globals = globals - - c.CmdClause = parent.Command("realtime", "View realtime stats for a Fastly service") - c.CmdClause.Flag("service-id", "Service ID").Short('s').Required().StringVar(&c.manifest.Flag.ServiceID) - - c.CmdClause.Flag("format", "Output format (json)").EnumVar(&c.formatFlag, "json") - - return &c -} - -// Exec implements the command interface. -func (c *RealtimeCommand) Exec(in io.Reader, out io.Writer) error { - service, source := c.manifest.ServiceID() - if source == manifest.SourceUndefined { - return errors.ErrNoServiceID - } - - switch c.formatFlag { - case "json": - if err := loopJSON(c.Globals.RTSClient, service, out); err != nil { - return err - } - - default: - if err := loopText(c.Globals.RTSClient, service, out); err != nil { - return err - } - } - - return nil -} - -func loopJSON(client api.RealtimeStatsInterface, service string, out io.Writer) error { - var timestamp uint64 - for { - var envelope struct { - Timestamp uint64 `json:"timestamp"` - Data []json.RawMessage `json:"data"` - } - - err := client.GetRealtimeStatsJSON(&fastly.GetRealtimeStatsInput{ - ServiceID: service, - Timestamp: timestamp, - }, &envelope) - if err != nil { - text.Error(out, "fetching stats: %w", err) - continue - } - timestamp = envelope.Timestamp - - for _, block := range envelope.Data { - out.Write(block) - text.Break(out) - } - } -} - -func loopText(client api.RealtimeStatsInterface, service string, out io.Writer) error { - var timestamp uint64 - for { - var envelope realtimeResponse - - err := client.GetRealtimeStatsJSON(&fastly.GetRealtimeStatsInput{ - ServiceID: service, - Timestamp: timestamp, - }, &envelope) - if err != nil { - text.Error(out, "fetching stats: %w", err) - continue - } - timestamp = envelope.Timestamp - - for _, block := range envelope.Data { - agg := block.Aggregated - - // FIXME: These are heavy-handed compatibility - // fixes for stats vs realtime, so we can use - // fmtBlock for both. - agg["start_time"] = block.Recorded - delete(agg, "miss_histogram") - - if err := fmtBlock(out, service, agg); err != nil { - text.Error(out, "formatting stats: %w", err) - continue - } - } - } -} diff --git a/pkg/stats/regions.go b/pkg/stats/regions.go deleted file mode 100644 index 9dd65cda0..000000000 --- a/pkg/stats/regions.go +++ /dev/null @@ -1,37 +0,0 @@ -package stats - -import ( - "fmt" - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/text" -) - -// RegionsCommand exposes the Stats Regions API. -type RegionsCommand struct { - common.Base -} - -// NewRegionsCommand returns a new command registered under parent. -func NewRegionsCommand(parent common.Registerer, globals *config.Data) *RegionsCommand { - var c RegionsCommand - c.Globals = globals - c.CmdClause = parent.Command("regions", "List stats regions") - return &c -} - -// Exec implements the command interface. -func (c *RegionsCommand) Exec(in io.Reader, out io.Writer) error { - resp, err := c.Globals.Client.GetRegions() - if err != nil { - return fmt.Errorf("fetching regions: %w", err) - } - - for _, region := range resp.Data { - text.Output(out, "%s", region) - } - - return nil -} diff --git a/pkg/stats/regions_test.go b/pkg/stats/regions_test.go deleted file mode 100644 index 11b4a2e79..000000000 --- a/pkg/stats/regions_test.go +++ /dev/null @@ -1,66 +0,0 @@ -package stats_test - -import ( - "bytes" - "errors" - "io" - "net/http" - "strings" - "testing" - - "github.com/fastly/cli/pkg/app" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/mock" - "github.com/fastly/cli/pkg/testutil" - "github.com/fastly/cli/pkg/update" - "github.com/fastly/go-fastly/v3/fastly" -) - -func TestRegions(t *testing.T) { - for _, testcase := range []struct { - args []string - api mock.API - wantError string - wantOutput string - }{ - { - args: []string{"stats", "regions"}, - api: mock.API{GetRegionsFn: getRegionsOK}, - wantOutput: "foo\nbar\nbaz\n", - }, - { - args: []string{"stats", "regions"}, - api: mock.API{GetRegionsFn: getRegionsError}, - wantError: errTest.Error(), - }, - } { - t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { - var ( - args = testcase.args - env = config.Environment{} - file = config.File{} - configFileName = "/dev/null" - clientFactory = mock.APIClient(testcase.api) - httpClient = http.DefaultClient - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, configFileName, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertStringContains(t, out.String(), testcase.wantOutput) - }) - } -} - -func getRegionsOK() (*fastly.RegionsResponse, error) { - return &fastly.RegionsResponse{ - Data: []string{"foo", "bar", "baz"}, - }, nil -} - -var errTest = errors.New("fixture error") - -func getRegionsError() (*fastly.RegionsResponse, error) { - return nil, errTest -} diff --git a/pkg/stats/root.go b/pkg/stats/root.go deleted file mode 100644 index 9d358f9ac..000000000 --- a/pkg/stats/root.go +++ /dev/null @@ -1,26 +0,0 @@ -package stats - -import ( - "io" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/config" -) - -// RootCommand dispatches all "stats" commands. -type RootCommand struct { - common.Base -} - -// NewRootCommand returns a new top level "stats" command. -func NewRootCommand(parent common.Registerer, globals *config.Data) *RootCommand { - var c RootCommand - c.Globals = globals - c.CmdClause = parent.Command("stats", "View statistics (historical and realtime) for a Fastly service") - return &c -} - -// Exec implements the command interface. -func (c *RootCommand) Exec(in io.Reader, out io.Writer) error { - panic("unreachable") -} diff --git a/pkg/stats/template.go b/pkg/stats/template.go deleted file mode 100644 index de010bc2f..000000000 --- a/pkg/stats/template.go +++ /dev/null @@ -1,76 +0,0 @@ -package stats - -import ( - "fmt" - "io" - "text/template" - "time" - - "github.com/fastly/go-fastly/v3/fastly" - "github.com/mitchellh/mapstructure" -) - -var blockTemplate = template.Must(template.New("stats_block").Parse( - `Service ID: {{ .ServiceID }} -Start Time: {{ .StartTime }} --------------------------------------------------- -Hit Rate: {{ .HitRate }} -Avg Hit Time: {{ .AvgHitTime }} -Avg Miss Time: {{ .AvgMissTime }} - -Request BW: {{ .RequestBytes }} - Headers: {{ .RequestHeaderBytes }} - Body: {{ .RequestBodyBytes }} - -Response BW: {{ .ResponseBytes }} - Headers: {{ .ResponseHeaderBytes }} - Body: {{ .ResponseBodyBytes }} - -Requests: {{ .RequestCount }} - Hit: {{ .Hits }} - Miss: {{ .Miss }} - Pass: {{ .Pass }} - Synth: {{ .Synth }} - Error: {{ .Errors }} - Uncacheable: {{ .Uncacheable }} - -`)) - -func fmtBlock(out io.Writer, service string, block statsResponseData) error { - var agg fastly.Stats - if err := mapstructure.Decode(block, &agg); err != nil { - return err - } - - hitRate := 0.0 - if agg.Hits > 0 { - hitRate = float64((agg.Hits - agg.Miss - agg.Errors)) / float64(agg.Hits) - } - - // TODO: parse the JSON more strictly so this doesn't need to be dynamic. - startTime := time.Unix(int64(block["start_time"].(float64)), 0).UTC() - - values := map[string]string{ - "ServiceID": fmt.Sprintf("%30s", service), - "StartTime": fmt.Sprintf("%30s", startTime), - "HitRate": fmt.Sprintf("%29.2f%%", hitRate*100), - "AvgHitTime": fmt.Sprintf("%28.2f\u00b5s", agg.HitsTime*1000), - "AvgMissTime": fmt.Sprintf("%28.2f\u00b5s", agg.MissTime*1000), - - "RequestBytes": fmt.Sprintf("%30d", agg.RequestHeaderBytes+agg.RequestBodyBytes), - "RequestHeaderBytes": fmt.Sprintf("%30d", agg.RequestHeaderBytes), - "RequestBodyBytes": fmt.Sprintf("%30d", agg.RequestBodyBytes), - "ResponseBytes": fmt.Sprintf("%30d", agg.ResponseHeaderBytes+agg.ResponseBodyBytes), - "ResponseHeaderBytes": fmt.Sprintf("%30d", agg.ResponseHeaderBytes), - "ResponseBodyBytes": fmt.Sprintf("%30d", agg.ResponseBodyBytes), - - "RequestCount": fmt.Sprintf("%30d", agg.Requests), - "Hits": fmt.Sprintf("%30d", agg.Hits), - "Miss": fmt.Sprintf("%30d", agg.Miss), - "Pass": fmt.Sprintf("%30d", agg.Pass), - "Synth": fmt.Sprintf("%30d", agg.Synth), - "Errors": fmt.Sprintf("%30d", agg.Errors), - "Uncacheable": fmt.Sprintf("%30d", agg.Uncachable)} - - return blockTemplate.Execute(out, values) -} diff --git a/pkg/sync/doc.go b/pkg/sync/doc.go new file mode 100644 index 000000000..07034d9ca --- /dev/null +++ b/pkg/sync/doc.go @@ -0,0 +1,2 @@ +// Package sync contains abstractions for working with concurrent writers. +package sync diff --git a/pkg/sync/sync.go b/pkg/sync/sync.go new file mode 100644 index 000000000..482118cad --- /dev/null +++ b/pkg/sync/sync.go @@ -0,0 +1,27 @@ +package sync + +import ( + "io" + "sync" +) + +// Writer protects any io.Writer with a mutex. +type Writer struct { + mtx sync.Mutex + // W is public to allow for type checking, but should otherwise not be accessed directly. + W io.Writer +} + +// NewWriter wraps an io.Writer with a mutex. +func NewWriter(w io.Writer) *Writer { + return &Writer{ + W: w, + } +} + +// Write implements io.Writer with mutex protection. +func (w *Writer) Write(p []byte) (int, error) { + w.mtx.Lock() + defer w.mtx.Unlock() + return w.W.Write(p) +} diff --git a/pkg/testutil/api.go b/pkg/testutil/api.go new file mode 100644 index 000000000..9b2991cdf --- /dev/null +++ b/pkg/testutil/api.go @@ -0,0 +1,124 @@ +package testutil + +import ( + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/commands/sso" + "github.com/fastly/cli/pkg/commands/whoami" +) + +// Err represents a generic error. +var Err = errors.New("test error") + +// ListVersions returns a list of service versions in different states. +// +// The first element is active, the second is locked, the third is +// editable, the fourth is staged. +// +// NOTE: consult the entire test suite before adding any new entries to the +// returned type as the tests currently use testutil.CloneVersionResult() as a +// way of making the test output and expectations as accurate as possible. +func ListVersions(i *fastly.ListVersionsInput) ([]*fastly.Version, error) { + return []*fastly.Version{ + { + ServiceID: fastly.ToPointer(i.ServiceID), + Number: fastly.ToPointer(1), + Active: fastly.ToPointer(true), + UpdatedAt: MustParseTimeRFC3339("2000-01-01T01:00:00Z"), + }, + { + ServiceID: fastly.ToPointer(i.ServiceID), + Number: fastly.ToPointer(2), + Locked: fastly.ToPointer(true), + UpdatedAt: MustParseTimeRFC3339("2000-01-02T01:00:00Z"), + }, + { + ServiceID: fastly.ToPointer(i.ServiceID), + Number: fastly.ToPointer(3), + UpdatedAt: MustParseTimeRFC3339("2000-01-03T01:00:00Z"), + }, + { + ServiceID: fastly.ToPointer(i.ServiceID), + Number: fastly.ToPointer(4), + Staging: fastly.ToPointer(true), + UpdatedAt: MustParseTimeRFC3339("2000-01-04T01:00:00Z"), + }, + }, nil +} + +// ListVersionsError returns a generic error message when attempting to list +// service versions. +func ListVersionsError(_ *fastly.ListVersionsInput) ([]*fastly.Version, error) { + return nil, Err +} + +// CloneVersionResult returns a function which returns a specific cloned version. +func CloneVersionResult(version int) func(i *fastly.CloneVersionInput) (*fastly.Version, error) { + return func(i *fastly.CloneVersionInput) (*fastly.Version, error) { + return &fastly.Version{ + ServiceID: fastly.ToPointer(i.ServiceID), + Number: fastly.ToPointer(version), + }, nil + } +} + +// CloneVersionError returns a generic error message when attempting to clone a +// service version. +func CloneVersionError(_ *fastly.CloneVersionInput) (*fastly.Version, error) { + return nil, Err +} + +// WhoamiVerifyClient is used by `whoami` and `sso` tests. +type WhoamiVerifyClient whoami.VerifyResponse + +// Do executes the HTTP request. +func (c WhoamiVerifyClient) Do(*http.Request) (*http.Response, error) { + rec := httptest.NewRecorder() + _ = json.NewEncoder(rec).Encode(whoami.VerifyResponse(c)) + return rec.Result(), nil +} + +// WhoamiBasicResponse is used by `whoami` and `sso` tests. +var WhoamiBasicResponse = whoami.VerifyResponse{ + Customer: whoami.Customer{ + ID: "abc", + Name: "Computer Company", + }, + User: whoami.User{ + ID: "123", + Name: "Alice Programmer", + Login: "alice@example.com", + }, + Services: map[string]string{ + "1xxaa": "First service", + "2baba": "Second service", + }, + Token: whoami.Token{ + ID: "abcdefg", + Name: "Token name", + CreatedAt: "2019-01-01T12:00:00Z", + // no ExpiresAt + Scope: "global", + }, +} + +// CurrentCustomerClient is used by `sso` tests. +type CurrentCustomerClient sso.CurrentCustomerResponse + +// Do executes the HTTP request. +func (c CurrentCustomerClient) Do(*http.Request) (*http.Response, error) { + rec := httptest.NewRecorder() + _ = json.NewEncoder(rec).Encode(sso.CurrentCustomerResponse(c)) + return rec.Result(), nil +} + +// CurrentCustomerResponse is used by `sso` tests. +var CurrentCustomerResponse = sso.CurrentCustomerResponse{ + ID: "abc", + Name: "Computer Company", +} diff --git a/pkg/testutil/args.go b/pkg/testutil/args.go new file mode 100644 index 000000000..6b94a811a --- /dev/null +++ b/pkg/testutil/args.go @@ -0,0 +1,151 @@ +package testutil + +import ( + "fmt" + "io" + "net/http" + "regexp" + "strings" + "time" + + "github.com/fastly/cli/pkg/auth" + "github.com/fastly/cli/pkg/config" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/manifest" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/runtime" +) + +var argsPattern = regexp.MustCompile("`.+`") + +// SplitArgs is a simple wrapper function designed to accept a CLI command +// (including flags) and return it as a slice for consumption by app.Run(). +// +// NOTE: One test file (TestBigQueryCreate) passes RSA content inline into the +// args string which means it has to escape the double quotes (used to infer +// the content should be considered a single argument) with a backtick. This +// causes problems when trying to split the args string by a space (as the RSA +// content has spaces) and so we need to be able to identify when backticks are +// used and ensure the backtick argument is considered a single argument (i.e. +// don't incorrectly split by the spaces within the RSA content when converting +// the arg string into a slice). +// +// The logic checks for backticks, and then replaces the content that is +// surrounded by backticks with --- and then splits the resulting string by +// spaces. Afterwards if there was a backtick matched, then we re-insert the +// backticked content into the slice where --- is found. +func SplitArgs(args string) []string { + var backtickMatch []string + + if strings.Contains(args, "`") { + backtickMatch = argsPattern.FindStringSubmatch(args) + args = argsPattern.ReplaceAllString(args, "---") + } + s := strings.Split(args, " ") + + if len(backtickMatch) > 0 { + for i, v := range s { + if v == "---" { + s[i] = backtickMatch[0] + } + } + } + + return s +} + +// MockAuthServer is used to no-op the authentication server. +type MockAuthServer struct { + auth.Runner + + Result chan auth.AuthorizationResult +} + +// SetParam sets the specified parameter for the authorization_endpoint. +func (s MockAuthServer) SetParam(_, _ string) { + // no-op +} + +// AuthURL returns a fully qualified authorization_endpoint. +// i.e. path + audience + scope + code_challenge etc. +func (s MockAuthServer) AuthURL() (string, error) { + return "", nil // no-op +} + +// GetResult returns the results channel. +func (s MockAuthServer) GetResult() chan auth.AuthorizationResult { + return s.Result +} + +// SetAPIEndpoint sets the API endpoint. +func (s MockAuthServer) SetAPIEndpoint(_ string) { + // no-op +} + +// Start starts a local server for handling authentication processing. +func (s MockAuthServer) Start() error { + return nil // no-op +} + +// MockGlobalData returns a struct that can be used to populate a call to app.Exec() +// while the majority of fields will be pre-populated and only those fields +// commonly changed for testing purposes will need to be provided. +// +// TODO: Move this and other mocks into mocks package. +func MockGlobalData(args []string, stdout io.Writer) *global.Data { + var md manifest.Data + md.File.Args = args + md.File.SetErrLog(errors.Log) + md.File.SetOutput(stdout) + _ = md.File.Read(manifest.Filename) + + configPath := "/dev/null" + if runtime.Windows { + configPath = "NUL" + } + + return &global.Data{ + Args: args, + APIClientFactory: mock.APIClient(mock.API{}), + AuthServer: &MockAuthServer{}, + Config: config.File{ + Profiles: TokenProfile(), + }, + ConfigPath: configPath, + Env: config.Environment{}, + ErrLog: errors.Log, + ExecuteWasmTools: func(bin string, args []string, d *global.Data) error { + fmt.Printf("bin: %s\n", bin) + fmt.Printf("args: %#v\n", args) + fmt.Printf("global: %#v\n", d) + return nil + }, + HTTPClient: &http.Client{Timeout: time.Second * 5}, + Manifest: &md, + Opener: func(input string) error { + fmt.Printf("%s\n", input) + return nil // no-op + }, + Output: stdout, + } +} + +// TokenProfile generates a mock profile token. +func TokenProfile() config.Profiles { + return config.Profiles{ + // IMPORTANT: Tests mock the token to prevent runtime panics. + // + // Tokens are now interactively handled unless a token is provided + // directly via the --token flag or the FASTLY_API_TOKEN env variable. + // + // We force the CLI to skip the interactive prompts by setting a default + // user profile and making sure the timestamp is not expired. + "user": &config.Profile{ + AccessTokenCreated: 9999999999, // Year: 2286 + Default: true, + Email: "test@example.com", + Token: "mock-token", + }, + } +} diff --git a/pkg/testutil/assert.go b/pkg/testutil/assert.go index 53f3f9e40..13a7bab2b 100644 --- a/pkg/testutil/assert.go +++ b/pkg/testutil/assert.go @@ -1,15 +1,19 @@ package testutil import ( + "fmt" + "reflect" "strings" "testing" - "github.com/fastly/cli/pkg/errors" "github.com/google/go-cmp/cmp" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/errors" ) // AssertEqual fatals a test if the parameters aren't equal. -func AssertEqual(t *testing.T, want, have interface{}) { +func AssertEqual(t *testing.T, want, have any) { t.Helper() if diff := cmp.Diff(want, have); diff != "" { t.Fatal(diff) @@ -40,6 +44,14 @@ func AssertStringContains(t *testing.T, s, substr string) { } } +// AssertStringDoesntContain fatals a test if the string does contain a substring. +func AssertStringDoesntContain(t *testing.T, s, substr string) { + t.Helper() + if strings.Contains(s, substr) { + t.Fatalf("%q contains %q", s, substr) + } +} + // AssertNoError fatals a test if the error is not nil. func AssertNoError(t *testing.T, err error) { t.Helper() @@ -59,7 +71,7 @@ func AssertErrorContains(t *testing.T, err error, target string) { case err == nil && target != "": t.Fatalf("want %q, have no error", target) case err != nil && target == "": - t.Fatalf("want no error, have %v", err) + t.Fatalf("want no error, have %q", err) case err != nil && target != "": if want, have := target, err.Error(); !strings.Contains(have, want) { t.Fatalf("want %q, have %q", want, have) @@ -88,3 +100,50 @@ func AssertRemediationErrorContains(t *testing.T, err error, target string) { } } } + +// AssertPathContentFlag errors a test scenario if the given flag value hasn't +// been parsed as expected. +// +// Example: Some flags will internally be passed to `argparser.Content` to acquire +// the value. If passed a file path, then we expect the testdata/ to +// have been read, otherwise we expect the given flag value to have been used. +func AssertPathContentFlag(flag string, wantError string, args []string, fixture string, content string, t *testing.T) { + if wantError == "" { + for i, a := range args { + if a == fmt.Sprintf("--%s", flag) { + want := args[i+1] + if want == fmt.Sprintf("./testdata/%s", fixture) { + want = argparser.Content(want) + } + if content != want { + t.Errorf("wanted %s, have %s", want, content) + } + break + } + } + } +} + +// Borrowed from https://github.com/stretchr/testify/blob/v1.9.0/assert/assertions.go#L778-L784 +func getLen(x any) (l int, ok bool) { + v := reflect.ValueOf(x) + defer func() { + ok = recover() == nil + }() + return v.Len(), true +} + +// AssertLength fails a test scenario if the given slice or string does +// not have the expected length. +func AssertLength(t *testing.T, want int, have any) { + t.Helper() + l, ok := getLen(have) + + if !ok { + t.Fatalf("cannot get len of type %T", have) + } + + if l != want { + t.Fatalf("wanted %d elements, got %d (%#v)", want, l, have) + } +} diff --git a/pkg/testutil/client.go b/pkg/testutil/client.go new file mode 100644 index 000000000..53899df69 --- /dev/null +++ b/pkg/testutil/client.go @@ -0,0 +1,15 @@ +package testutil + +import "net/http" + +// MockRoundTripper implements [http.RoundTripper] for mocking HTTP responses. +type MockRoundTripper struct { + Response *http.Response + Err error +} + +// RoundTrip executes a single HTTP transaction, returning a Response for the +// provided Request. +func (m *MockRoundTripper) RoundTrip(_ *http.Request) (*http.Response, error) { + return m.Response, m.Err +} diff --git a/pkg/testutil/env.go b/pkg/testutil/env.go new file mode 100644 index 000000000..32b03f9c4 --- /dev/null +++ b/pkg/testutil/env.go @@ -0,0 +1,102 @@ +package testutil + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// FileIO represents a source file and a destination. +type FileIO struct { + Src string // path to a file inside ./testdata/ OR file content + Dst string // path to a file relative to test environment's root directory + Executable bool // if path can be executed as a binary +} + +// EnvOpts represents configuration when creating a new environment. +type EnvOpts struct { + T *testing.T + Dirs []string // expect path to have a trailing slash (will be added if missing) + Copy []FileIO // .Src expected to be file path + Write []FileIO // .Src expected to be file content + Exec []string // e.g. []string{"npm", "install"} +} + +// NewEnv creates a new test environment and returns the root directory. +func NewEnv(opts EnvOpts) (rootdir string) { + rootdir, err := os.MkdirTemp("", "fastly-temp-*") + if err != nil { + opts.T.Fatal(err) + } + + if err := os.MkdirAll(rootdir, 0o750); err != nil { + opts.T.Fatal(err) + } + + for _, d := range opts.Dirs { + d = strings.TrimRight(d, "/") + "/filename-required.txt" + createIntermediaryDirectories(d, rootdir, opts.T) + } + + for _, f := range opts.Copy { + src := f.Src + dst := filepath.Join(rootdir, f.Dst) + CopyFile(opts.T, src, dst) + } + + for _, f := range opts.Write { + if f.Src == "" { + continue + } + src := f.Src + dst := filepath.Join(rootdir, f.Dst) + + // Ensure any intermediary directories exist before trying to write the + // given file to disk. + createIntermediaryDirectories(f.Dst, rootdir, opts.T) + + if err := os.WriteFile(dst, []byte(src), 0o777); err != nil /* #nosec */ { + opts.T.Fatal(err) + } + if f.Executable { + if err := os.Chmod(dst, os.FileMode(0o755)); err != nil { + opts.T.Fatal(err) + } + } + } + + if len(opts.Exec) > 0 { + // gosec flagged this: + // G204 (CWE-78): Subprocess launched with function call as argument or cmd arguments + // Disabling as we trust the source of the variable. + // #nosec + // nosemgrep: go.lang.security.audit.dangerous-exec-command.dangerous-exec-command + cmd := exec.Command(opts.Exec[0], opts.Exec[1:]...) + cmd.Dir = rootdir + if err := cmd.Run(); err != nil { + opts.T.Fatal(err) + } + } + + return rootdir +} + +// createIntermediaryDirectories strips the filename from the given path and +// appends it to the rootdir so that we can use MkdirAll to create the +// directory and all its intermediary directories. +// +// EXAMPLE: /foo/bar/baz.txt will create the foo and bar directories if they +// don't already exist. +// +// NOTE: If path is just a filename (e.g. config.toml), then this function +// won't necessarily trigger a test failure because we would end up appending +// an empty string to the rootdir and so the MkdirAll call still succeeds. +func createIntermediaryDirectories(path, rootdir string, t *testing.T) { + intermediary := strings.Replace(path, filepath.Base(path), "", 1) + intermediary = filepath.Join(rootdir, intermediary) + if err := os.MkdirAll(intermediary, 0o750); err != nil { + t.Fatal(err) + } +} diff --git a/pkg/testutil/file.go b/pkg/testutil/file.go index 49e137e57..ac48c51f4 100644 --- a/pkg/testutil/file.go +++ b/pkg/testutil/file.go @@ -1,11 +1,13 @@ package testutil import ( + "io" "os" + "path/filepath" "testing" ) -// MakeTempFile creates a tempfile with the given contents and returns its path +// MakeTempFile creates a tempfile with the given contents and returns its path. func MakeTempFile(t *testing.T, contents string) string { t.Helper() @@ -18,3 +20,48 @@ func MakeTempFile(t *testing.T, contents string) string { } return tmpfile.Name() } + +// CopyFile copies a referenced file to a new location. +func CopyFile(t *testing.T, fromFilename, toFilename string) { + t.Helper() + + // gosec flagged this: + // G304 (CWE-22): Potential file inclusion via variable + // Disabling as we trust the source of the variable. + /* #nosec */ + src, err := os.Open(fromFilename) + if err != nil { + t.Fatal(err) + } + defer func() { + if err := src.Close(); err != nil { + t.Errorf("Failed to close fromFilename: %v", err) + } + }() + + toDir := filepath.Dir(toFilename) + if err := os.MkdirAll(toDir, 0o750); err != nil { + t.Fatal(err) + } + + // gosec flagged this: + // G304 (CWE-22): Potential file inclusion via variable + // Disabling as we trust the source of the variable. + /* #nosec */ + dst, err := os.Create(toFilename) + if err != nil { + t.Fatal(err) + } + + if _, err := io.Copy(dst, src); err != nil { + t.Fatal(err) + } + + if err := dst.Sync(); err != nil { + t.Fatal(err) + } + + if err := dst.Close(); err != nil { + t.Fatal(err) + } +} diff --git a/pkg/testutil/json.go b/pkg/testutil/json.go new file mode 100644 index 000000000..68208d13c --- /dev/null +++ b/pkg/testutil/json.go @@ -0,0 +1,12 @@ +package testutil + +import "encoding/json" + +// GenJSON returns JSON encoding of data, or empty object in case of an error. +func GenJSON(data any) []byte { + b, err := json.MarshalIndent(data, "", " ") + if err != nil { + return []byte("{}") + } + return b +} diff --git a/pkg/testutil/log.go b/pkg/testutil/log.go new file mode 100644 index 000000000..d0d41c88b --- /dev/null +++ b/pkg/testutil/log.go @@ -0,0 +1,12 @@ +package testutil + +import "testing" + +// LogWriter is used to debug issues with our tests. +type LogWriter struct{ T *testing.T } + +func (w LogWriter) Write(p []byte) (int, error) { + // NOTE: text printed only if test fails or -test.v set + w.T.Log(string(p)) + return len(p), nil +} diff --git a/pkg/testutil/paginator.go b/pkg/testutil/paginator.go new file mode 100644 index 000000000..d6dbc05d3 --- /dev/null +++ b/pkg/testutil/paginator.go @@ -0,0 +1,90 @@ +package testutil + +import ( + "github.com/fastly/go-fastly/v10/fastly" +) + +// ServicesPaginator mocks the behaviour of a paginator for services. +type ServicesPaginator struct { + Count int + MaxPages int + NumOfPages int + RequestedPage int + ReturnErr bool +} + +// HasNext indicates if there is another page of data. +func (p *ServicesPaginator) HasNext() bool { + if p.Count > p.MaxPages { + return false + } + p.Count++ + return true +} + +// Remaining returns the count of remaining pages. +func (p ServicesPaginator) Remaining() int { + return 1 +} + +// GetNext returns the next page of data. +func (p *ServicesPaginator) GetNext() (ss []*fastly.Service, err error) { + if p.ReturnErr { + err = Err + } + pageOne := fastly.Service{ + ServiceID: fastly.ToPointer("123"), + Name: fastly.ToPointer("Foo"), + Type: fastly.ToPointer("wasm"), + CustomerID: fastly.ToPointer("mycustomerid"), + ActiveVersion: fastly.ToPointer(2), + UpdatedAt: MustParseTimeRFC3339("2010-11-15T19:01:02Z"), + Versions: []*fastly.Version{ + { + Number: fastly.ToPointer(1), + Comment: fastly.ToPointer("a"), + ServiceID: fastly.ToPointer("b"), + CreatedAt: MustParseTimeRFC3339("2001-02-03T04:05:06Z"), + UpdatedAt: MustParseTimeRFC3339("2001-02-04T04:05:06Z"), + DeletedAt: MustParseTimeRFC3339("2001-02-05T04:05:06Z"), + }, + { + Number: fastly.ToPointer(2), + Comment: fastly.ToPointer("c"), + ServiceID: fastly.ToPointer("d"), + Active: fastly.ToPointer(true), + Deployed: fastly.ToPointer(true), + CreatedAt: MustParseTimeRFC3339("2001-03-03T04:05:06Z"), + UpdatedAt: MustParseTimeRFC3339("2001-03-04T04:05:06Z"), + }, + }, + } + pageTwo := fastly.Service{ + ServiceID: fastly.ToPointer("456"), + Name: fastly.ToPointer("Bar"), + Type: fastly.ToPointer("wasm"), + CustomerID: fastly.ToPointer("mycustomerid"), + ActiveVersion: fastly.ToPointer(1), + UpdatedAt: MustParseTimeRFC3339("2015-03-14T12:59:59Z"), + } + pageThree := fastly.Service{ + ServiceID: fastly.ToPointer("789"), + Name: fastly.ToPointer("Baz"), + Type: fastly.ToPointer("vcl"), + CustomerID: fastly.ToPointer("mycustomerid"), + ActiveVersion: fastly.ToPointer(1), + } + if p.Count == 1 { + ss = append(ss, &pageOne) + } + if p.Count == 2 { + ss = append(ss, &pageTwo) + } + if p.Count == 3 { + ss = append(ss, &pageThree) + } + if p.RequestedPage > 0 && p.NumOfPages == 1 { + p.Count = p.MaxPages + 1 // forces only one result to be displayed + } + return ss, err +} diff --git a/pkg/testutil/scenarios.go b/pkg/testutil/scenarios.go new file mode 100644 index 000000000..3efc61fbb --- /dev/null +++ b/pkg/testutil/scenarios.go @@ -0,0 +1,268 @@ +package testutil + +import ( + "fmt" + "io" + "net/http" + "os" + "slices" + "strings" + "testing" + "time" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/api" + "github.com/fastly/cli/pkg/app" + "github.com/fastly/cli/pkg/config" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/threadsafe" +) + +// CLIScenario represents a CLI test case to be validated. +// +// Most of the fields in this struct are optional; if they are not +// provided RunCLIScenario will not apply the behavior indicated for +// those fields. +type CLIScenario struct { + // API is a mock API implementation which can be used by the + // command under test + API mock.API + // Args is the input arguments for the command to execute (not + // including the command names themselves). + Args string + // Client is a mock http.Client that will be used as part of a + // *fastly.Client instance passed into the test code. + Client *http.Client + // ConfigPath will be copied into global.Data.ConfigPath + ConfigPath string + // ConfigFile will be copied into global.Data.ConfigFile + ConfigFile *config.File + // DontWantOutput will cause the scenario to fail if the + // string appears in stdout + DontWantOutput string + // DontWantOutputs will cause the scenario to fail if any of + // the strings appear in stdout + DontWantOutputs []string + Env *EnvConfig + // EnvVars contains environment variables which will be set + // during the execution of the scenario + EnvVars map[string]string + // Name appears in output when tests are executed + Name string + PathContentFlag *PathContentFlag + // Setup function can perform additional setup before the scenario is run + Setup func(t *testing.T, scenario *CLIScenario, opts *global.Data) + // Stdin contains input to be read by the application + Stdin []string + // Validator function can perform additional validation on the results + // of the scenario + Validator func(t *testing.T, scenario *CLIScenario, opts *global.Data, stdout *threadsafe.Buffer) + // WantError will cause the scenario to fail if this string + // does not appear in an Error + WantError string + // WantOutput will cause the scenario to fail if this string + // does not appear in stdout + WantOutput string + // WantOutputs will cause the scenario to fail if any of the + // strings do not appear in stdout + WantOutputs []string +} + +// PathContentFlag provides the details required to validate that a +// flag value has been parsed correctly by the argument parser. +type PathContentFlag struct { + Flag string + Fixture string + Content func() string +} + +// EnvConfig provides the details required to setup a temporary test +// environment, and optionally a function to run which accepts the +// environment directory and can modify fields in the CLIScenario. +type EnvConfig struct { + Opts *EnvOpts + // EditScenario holds a function which will be called after + // the temporary environment has been created but before the + // scenario setup (and execution) begin; it can make any + // modifications to the CLIScenario that are needed + EditScenario func(*CLIScenario, string) +} + +// RunCLIScenario executes a CLIScenario struct. +// The Arg field of the scenario is prepended with the content of the 'command' +// slice passed in to construct the complete command to be executed. +func RunCLIScenario(t *testing.T, command []string, scenario CLIScenario) { + t.Run(scenario.Name, func(t *testing.T) { + var ( + err error + fullargs []string + rootdir string + stdout threadsafe.Buffer + ) + + if len(scenario.Args) > 0 { + fullargs = slices.Concat(command, SplitArgs(scenario.Args)) + } else { + fullargs = command + } + + opts := MockGlobalData(fullargs, &stdout) + + // NOTE: The go-fastly API client has changed design. + // It has started to move away from methods on the client instance. + // Instead it has started to expose functions that accept a client. + // This means for test mocking we have to adjust the mock approach. + var acf global.APIClientFactory + if scenario.Client != nil { + acf = func(_, _ string, _ bool) (api.Interface, error) { + fc, err := fastly.NewClientForEndpoint("no-key", "api.example.com") + if err != nil { + return nil, fmt.Errorf("failed to mock fastly.Client: %w", err) + } + fc.HTTPClient = scenario.Client + return fc, nil + } + } else { + acf = mock.APIClient(scenario.API) + } + opts.APIClientFactory = acf + + if scenario.Env != nil { + // We're going to chdir to a deploy environment, + // so save the PWD to return to, afterwards. + pwd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + + // Create test environment + scenario.Env.Opts.T = t + rootdir = NewEnv(*scenario.Env.Opts) + defer os.RemoveAll(rootdir) + + // Before running the test, chdir into the build environment. + // When we're done, chdir back to our original location. + // This is so we can reliably copy the testdata/ fixtures. + if err := os.Chdir(rootdir); err != nil { + t.Fatal(err) + } + defer func() { + _ = os.Chdir(pwd) + }() + + if scenario.Env.EditScenario != nil { + scenario.Env.EditScenario(&scenario, rootdir) + } + } + + if len(scenario.ConfigPath) > 0 { + opts.ConfigPath = scenario.ConfigPath + } + + if scenario.ConfigFile != nil { + opts.Config = *scenario.ConfigFile + } + + if scenario.EnvVars != nil { + for key, value := range scenario.EnvVars { + if err := os.Setenv(key, value); err != nil { + t.Fatal(err) + } + defer func() { + if err := os.Unsetenv(key); err != nil { + t.Fatal(err) + } + }() + } + } + + if scenario.Setup != nil { + scenario.Setup(t, &scenario, opts) + } + + if len(scenario.Stdin) > 1 { + // To handle multiple prompt input from the user we need to do some + // coordination around io pipes to mimic the required user behaviour. + stdin, prompt := io.Pipe() + opts.Input = stdin + + // Wait for user input and write it to the prompt + inputc := make(chan string) + go func() { + for input := range inputc { + fmt.Fprintln(prompt, input) + } + }() + + // We need a channel so we wait for `run()` to complete + done := make(chan bool) + + // Call `app.Run()` and wait for response + go func() { + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + return opts, nil + } + err = app.Run(fullargs, nil) + done <- true + }() + + // User provides input + // + // NOTE: Must provide as much input as is expected to be waited on by `run()`. + // For example, if `run()` calls `input()` twice, then provide two messages. + // Otherwise the select statement will trigger the timeout error. + for _, input := range scenario.Stdin { + inputc <- input + } + + select { + case <-done: + // Wait for app.Run() to finish + case <-time.After(time.Second): + t.Fatalf("unexpected timeout waiting for mocked prompt inputs to be processed") + } + } else { + stdin := "" + if len(scenario.Stdin) > 0 { + stdin = scenario.Stdin[0] + } + opts.Input = strings.NewReader(stdin) + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + return opts, nil + } + err = app.Run(fullargs, nil) + } + + AssertErrorContains(t, err, scenario.WantError) + AssertStringContains(t, stdout.String(), scenario.WantOutput) + + for _, want := range scenario.WantOutputs { + AssertStringContains(t, stdout.String(), want) + } + + if len(scenario.DontWantOutput) > 0 { + AssertStringDoesntContain(t, stdout.String(), scenario.DontWantOutput) + } + for _, want := range scenario.DontWantOutputs { + AssertStringDoesntContain(t, stdout.String(), want) + } + + if scenario.PathContentFlag != nil { + pcf := *scenario.PathContentFlag + AssertPathContentFlag(pcf.Flag, scenario.WantError, fullargs, pcf.Fixture, pcf.Content(), t) + } + + if scenario.Validator != nil { + scenario.Validator(t, &scenario, opts, &stdout) + } + }) +} + +// RunCLIScenarios executes the CLIScenario structs from the slice passed in. +func RunCLIScenarios(t *testing.T, command []string, scenarios []CLIScenario) { + for _, scenario := range scenarios { + RunCLIScenario(t, command, scenario) + } +} diff --git a/pkg/testutil/string.go b/pkg/testutil/string.go new file mode 100644 index 000000000..2e23a8136 --- /dev/null +++ b/pkg/testutil/string.go @@ -0,0 +1,8 @@ +package testutil + +import "strings" + +// StripNewLines removes all newline delimiters. +func StripNewLines(s string) string { + return strings.ReplaceAll(s, "\n", "") +} diff --git a/pkg/testutil/time.go b/pkg/testutil/time.go new file mode 100644 index 000000000..9dbec5037 --- /dev/null +++ b/pkg/testutil/time.go @@ -0,0 +1,6 @@ +package testutil + +import "time" + +// Date is a consistent date object used by all tests. +var Date = time.Date(2021, time.June, 15, 23, 0, 0, 0, time.UTC) diff --git a/pkg/text/accesskey.go b/pkg/text/accesskey.go new file mode 100644 index 000000000..aaf53d299 --- /dev/null +++ b/pkg/text/accesskey.go @@ -0,0 +1,37 @@ +package text + +import ( + "fmt" + "io" + + "github.com/fastly/cli/pkg/time" + "github.com/fastly/go-fastly/v10/fastly/objectstorage/accesskeys" +) + +// PrintAccessKey displays an access key. +func PrintAccessKey(out io.Writer, accessKey *accesskeys.AccessKey) { + fmt.Fprintf(out, "ID: %s\n", accessKey.AccessKeyID) + fmt.Fprintf(out, "Secret: %s\n", accessKey.SecretKey) + fmt.Fprintf(out, "Description: %s\n", accessKey.Description) + fmt.Fprintf(out, "Permission: %s\n", accessKey.Permission) + fmt.Fprintf(out, "Buckets: %s\n", accessKey.Buckets) + fmt.Fprintf(out, "Created (UTC): %s\n", accessKey.CreatedAt.UTC().Format(time.Format)) +} + +// PrintAccessKeyTbl displays access keys in a table format. +func PrintAccessKeyTbl(out io.Writer, accessKeys []accesskeys.AccessKey) { + tbl := NewTable(out) + tbl.AddHeader("ID", "Secret", "Description", "Permssion", "Buckets", "Created At") + + if accessKeys == nil { + tbl.Print() + return + } + + for _, accessKey := range accessKeys { + // avoid gosec loop aliasing check :/ + accessKey := accessKey + tbl.AddLine(accessKey.AccessKeyID, accessKey.SecretKey, accessKey.Description, accessKey.Permission, accessKey.Buckets, accessKey.CreatedAt) + } + tbl.Print() +} diff --git a/pkg/text/backend.go b/pkg/text/backend.go index 914f245ac..6f171e149 100644 --- a/pkg/text/backend.go +++ b/pkg/text/backend.go @@ -4,8 +4,9 @@ import ( "fmt" "io" - "github.com/fastly/go-fastly/v3/fastly" "github.com/segmentio/textio" + + "github.com/fastly/go-fastly/v10/fastly" ) // PrintBackend pretty prints a fastly.Backend structure in verbose format @@ -14,27 +15,36 @@ import ( func PrintBackend(out io.Writer, prefix string, b *fastly.Backend) { out = textio.NewPrefixWriter(out, prefix) - fmt.Fprintf(out, "Name: %s\n", b.Name) - fmt.Fprintf(out, "Comment: %v\n", b.Comment) - fmt.Fprintf(out, "Address: %v\n", b.Address) - fmt.Fprintf(out, "Port: %v\n", b.Port) - fmt.Fprintf(out, "Override host: %v\n", b.OverrideHost) - fmt.Fprintf(out, "Connect timeout: %v\n", b.ConnectTimeout) - fmt.Fprintf(out, "Max connections: %v\n", b.MaxConn) - fmt.Fprintf(out, "First byte timeout: %v\n", b.FirstByteTimeout) - fmt.Fprintf(out, "Between bytes timeout: %v\n", b.BetweenBytesTimeout) - fmt.Fprintf(out, "Auto loadbalance: %v\n", b.AutoLoadbalance) - fmt.Fprintf(out, "Weight: %v\n", b.Weight) - fmt.Fprintf(out, "Healthcheck: %v\n", b.HealthCheck) - fmt.Fprintf(out, "Shield: %v\n", b.Shield) - fmt.Fprintf(out, "Use SSL: %v\n", b.UseSSL) - fmt.Fprintf(out, "SSL check cert: %v\n", b.SSLCheckCert) - fmt.Fprintf(out, "SSL CA cert: %v\n", b.SSLCACert) - fmt.Fprintf(out, "SSL client cert: %v\n", b.SSLClientCert) - fmt.Fprintf(out, "SSL client key: %v\n", b.SSLClientKey) - fmt.Fprintf(out, "SSL cert hostname: %v\n", b.SSLCertHostname) - fmt.Fprintf(out, "SSL SNI hostname: %v\n", b.SSLSNIHostname) - fmt.Fprintf(out, "Min TLS version: %v\n", b.MinTLSVersion) - fmt.Fprintf(out, "Max TLS version: %v\n", b.MaxTLSVersion) - fmt.Fprintf(out, "SSL ciphers: %v\n", b.SSLCiphers) + fmt.Fprintf(out, "Name: %s\n", fastly.ToValue(b.Name)) + fmt.Fprintf(out, "Comment: %v\n", fastly.ToValue(b.Comment)) + fmt.Fprintf(out, "Address: %v\n", fastly.ToValue(b.Address)) + fmt.Fprintf(out, "Port: %v\n", fastly.ToValue(b.Port)) + fmt.Fprintf(out, "Override host: %v\n", fastly.ToValue(b.OverrideHost)) + fmt.Fprintf(out, "Connect timeout: %v\n", fastly.ToValue(b.ConnectTimeout)) + fmt.Fprintf(out, "Max connections: %v\n", fastly.ToValue(b.MaxConn)) + fmt.Fprintf(out, "First byte timeout: %v\n", fastly.ToValue(b.FirstByteTimeout)) + fmt.Fprintf(out, "Between bytes timeout: %v\n", fastly.ToValue(b.BetweenBytesTimeout)) + fmt.Fprintf(out, "Auto loadbalance: %v\n", fastly.ToValue(b.AutoLoadbalance)) + fmt.Fprintf(out, "Weight: %v\n", fastly.ToValue(b.Weight)) + fmt.Fprintf(out, "Healthcheck: %v\n", fastly.ToValue(b.HealthCheck)) + fmt.Fprintf(out, "Shield: %v\n", fastly.ToValue(b.Shield)) + fmt.Fprintf(out, "Use SSL: %v\n", fastly.ToValue(b.UseSSL)) + fmt.Fprintf(out, "SSL check cert: %v\n", fastly.ToValue(b.SSLCheckCert)) + fmt.Fprintf(out, "SSL CA cert: %v\n", fastly.ToValue(b.SSLCACert)) + fmt.Fprintf(out, "SSL client cert: %v\n", fastly.ToValue(b.SSLClientCert)) + fmt.Fprintf(out, "SSL client key: %v\n", fastly.ToValue(b.SSLClientKey)) + fmt.Fprintf(out, "SSL cert hostname: %v\n", fastly.ToValue(b.SSLCertHostname)) + fmt.Fprintf(out, "SSL SNI hostname: %v\n", fastly.ToValue(b.SSLSNIHostname)) + fmt.Fprintf(out, "Min TLS version: %v\n", fastly.ToValue(b.MinTLSVersion)) + fmt.Fprintf(out, "Max TLS version: %v\n", fastly.ToValue(b.MaxTLSVersion)) + fmt.Fprintf(out, "SSL ciphers: %v\n", fastly.ToValue(b.SSLCiphers)) + fmt.Fprintf(out, "HTTP KeepAlive Timeout: %v\n", fastly.ToValue(b.KeepAliveTime)) + if b.TCPKeepAliveEnable == nil { + fmt.Fprintf(out, "TCP KeepAlive Enabled: unset\n") + } else { + fmt.Fprintf(out, "TCP KeepAlive Enabled: %v\n", fastly.ToValue(b.TCPKeepAliveEnable)) + } + fmt.Fprintf(out, "TCP KeepAlive Interval: %v\n", fastly.ToValue(b.TCPKeepAliveIntvl)) + fmt.Fprintf(out, "TCP KeepAlive Probes: %v\n", fastly.ToValue(b.TCPKeepAliveProbes)) + fmt.Fprintf(out, "TCP KeepAlive Timeout: %v\n", fastly.ToValue(b.TCPKeepAliveTime)) } diff --git a/pkg/text/color.go b/pkg/text/color.go index a5f59ca2d..6836a63e5 100644 --- a/pkg/text/color.go +++ b/pkg/text/color.go @@ -1,10 +1,15 @@ package text -import "github.com/fatih/color" +import ( + "github.com/fatih/color" +) // Bold is a Sprint-class function that makes the arguments bold. var Bold = color.New(color.Bold).SprintFunc() +// BoldCyan is a Sprint-class function that makes the arguments bold and cyan. +var BoldCyan = color.New(color.Bold, color.FgCyan).SprintFunc() + // BoldRed is a Sprint-class function that makes the arguments bold and red. var BoldRed = color.New(color.Bold, color.FgRed).SprintFunc() @@ -16,3 +21,13 @@ var BoldGreen = color.New(color.Bold, color.FgGreen).SprintFunc() // Reset is a Sprint-class function that resets the color for the arguments. var Reset = color.New(color.Reset).SprintFunc() + +// Prompt is a Sprint-class function that makes the arguments bold and uses the +// default colour for the terminal. +// +// IMPORTANT: Be careful modifying with Black or White as this can break themes. +// e.g. Black with Solarized Dark makes the text invisible! +var Prompt = color.New(color.Bold).SprintFunc() + +// ColorFn is a function returned from a color.SprintFunc() call. +type ColorFn func(a ...any) string diff --git a/pkg/text/computeacl.go b/pkg/text/computeacl.go new file mode 100644 index 000000000..b77c8848a --- /dev/null +++ b/pkg/text/computeacl.go @@ -0,0 +1,62 @@ +package text + +import ( + "fmt" + "io" + + "github.com/segmentio/textio" + + "github.com/fastly/go-fastly/v10/fastly/computeacls" +) + +// PrintComputeACL displays a compute ACL. +func PrintComputeACL(out io.Writer, prefix string, acl *computeacls.ComputeACL) { + out = textio.NewPrefixWriter(out, prefix) + + fmt.Fprintf(out, "ID: %s\n", acl.ComputeACLID) + fmt.Fprintf(out, "Name: %s\n", acl.Name) +} + +// PrintComputeACLsTbl displays compute ACLs in a table format. +func PrintComputeACLsTbl(out io.Writer, acls []computeacls.ComputeACL) { + tbl := NewTable(out) + tbl.AddHeader("Name", "ID") + + if acls == nil { + tbl.Print() + return + } + + for _, acl := range acls { + // avoid gosec loop aliasing check :/ + acl := acl + tbl.AddLine(acl.Name, acl.ComputeACLID) + } + tbl.Print() +} + +// PrintComputeACLEntry displays a compute ACL entry. +func PrintComputeACLEntry(out io.Writer, prefix string, entry *computeacls.ComputeACLEntry) { + out = textio.NewPrefixWriter(out, prefix) + + fmt.Fprintf(out, "Prefix: %s\n", entry.Prefix) + fmt.Fprintf(out, "Action: %s\n", entry.Action) +} + +// PrintComputeACLEntriesTbl displays compute ACL entries in a table format. +func PrintComputeACLEntriesTbl(out io.Writer, entries []computeacls.ComputeACLEntry) { + tbl := NewTable(out) + tbl.AddHeader("Prefix", "Action") + + if entries == nil { + tbl.Print() + return + } + + for _, entry := range entries { + // avoid gosec loop aliasing check :/ + entry := entry + tbl.AddLine(entry.Prefix, entry.Action) + } + tbl.Print() +} diff --git a/pkg/text/configstore.go b/pkg/text/configstore.go new file mode 100644 index 000000000..ff8c5648d --- /dev/null +++ b/pkg/text/configstore.go @@ -0,0 +1,113 @@ +package text + +import ( + "fmt" + "io" + "strconv" + "time" + + "github.com/segmentio/textio" + + "github.com/fastly/go-fastly/v10/fastly" + + fsttime "github.com/fastly/cli/pkg/time" +) + +// PrintConfigStoresTbl displays store data in a table format. +func PrintConfigStoresTbl(out io.Writer, stores []*fastly.ConfigStore) { + tbl := NewTable(out) + tbl.AddHeader("Name", "ID", "Created (UTC)", "Updated (UTC)") + + if stores == nil { + tbl.Print() + return + } + + for _, cs := range stores { + // avoid gosec loop aliasing check :/ + cs := cs + tbl.AddLine(cs.Name, cs.StoreID, fmtConfigStoreTime(cs.CreatedAt), fmtConfigStoreTime(cs.UpdatedAt)) + } + tbl.Print() +} + +// PrintConfigStore displays store data and optional metadata (may be nil). +func PrintConfigStore(out io.Writer, cs *fastly.ConfigStore, csm *fastly.ConfigStoreMetadata) { + out = textio.NewPrefixWriter(out, "") + + fmt.Fprintf(out, "Name: %s\n", cs.Name) + fmt.Fprintf(out, "ID: %s\n", cs.StoreID) + fmt.Fprintf(out, "Created (UTC): %s\n", fmtConfigStoreTime(cs.CreatedAt)) + fmt.Fprintf(out, "Updated (UTC): %s\n", fmtConfigStoreTime(cs.UpdatedAt)) + if csm != nil { + fmt.Fprintf(out, "Item Count: %d\n", csm.ItemCount) + } +} + +// PrintConfigStoreServicesTbl displays table of a config store's services. +func PrintConfigStoreServicesTbl(out io.Writer, s []*fastly.Service) { + tw := NewTable(out) + tw.AddHeader("NAME", "ID", "TYPE") + for _, service := range s { + tw.AddLine( + fastly.ToValue(service.Name), + fastly.ToValue(service.ServiceID), + fastly.ToValue(service.Type), + ) + } + tw.Print() +} + +func fmtConfigStoreTime(t *time.Time) string { + if t == nil { + return "n/a" + } + return t.UTC().Format(fsttime.Format) +} + +// PrintConfigStoreItemsTbl displays store item data in a table format. +func PrintConfigStoreItemsTbl(out io.Writer, items []*fastly.ConfigStoreItem) { + tbl := NewTable(out) + tbl.AddHeader("Key", "Value", "Created (UTC)", "Updated (UTC)") + + if items == nil { + tbl.Print() + return + } + + for _, csi := range items { + // avoid gosec loop aliasing check :/ + csi := csi + + // Quote and truncate 'value' to an arbitrary length. + // Note that this operates on the number of bytes, and not + // character or grapheme clusters. + value := csi.Value + var truncated bool + if len(csi.Value) > 64 { + value = value[:64] + truncated = true + } + value = strconv.Quote(value) + if truncated { + value += " (truncated)" + } + + tbl.AddLine(csi.Key, value, fmtConfigStoreTime(csi.CreatedAt), fmtConfigStoreTime(csi.UpdatedAt)) + } + tbl.Print() +} + +// PrintConfigStoreItem displays store item data. +func PrintConfigStoreItem(out io.Writer, prefix string, csi *fastly.ConfigStoreItem) { + out = textio.NewPrefixWriter(out, prefix) + + fmt.Fprintf(out, "StoreID: %s\n", csi.StoreID) + fmt.Fprintf(out, "Key: %s\n", csi.Key) + fmt.Fprintf(out, "Value: %s\n", csi.Value) + fmt.Fprintf(out, "Created (UTC): %s\n", fmtConfigStoreTime(csi.CreatedAt)) + fmt.Fprintf(out, "Updated (UTC): %s\n", fmtConfigStoreTime(csi.UpdatedAt)) + if csi.DeletedAt != nil { + fmt.Fprintf(out, "Deleted (UTC): %s\n", fmtConfigStoreTime(csi.DeletedAt)) + } +} diff --git a/pkg/text/dictionary.go b/pkg/text/dictionary.go index 8acc46854..e552cec76 100644 --- a/pkg/text/dictionary.go +++ b/pkg/text/dictionary.go @@ -4,9 +4,11 @@ import ( "fmt" "io" - "github.com/fastly/cli/pkg/common" - "github.com/fastly/go-fastly/v3/fastly" "github.com/segmentio/textio" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/time" ) // PrintDictionary pretty prints a fastly.Dictionary structure in verbose @@ -15,12 +17,12 @@ import ( func PrintDictionary(out io.Writer, prefix string, d *fastly.Dictionary) { out = textio.NewPrefixWriter(out, prefix) - fmt.Fprintf(out, "ID: %s\n", d.ID) - fmt.Fprintf(out, "Name: %s\n", d.Name) - fmt.Fprintf(out, "Write Only: %t\n", d.WriteOnly) - fmt.Fprintf(out, "Created (UTC): %s\n", d.CreatedAt.UTC().Format(common.TimeFormat)) - fmt.Fprintf(out, "Last edited (UTC): %s\n", d.UpdatedAt.UTC().Format(common.TimeFormat)) + fmt.Fprintf(out, "ID: %s\n", fastly.ToValue(d.DictionaryID)) + fmt.Fprintf(out, "Name: %s\n", fastly.ToValue(d.Name)) + fmt.Fprintf(out, "Write Only: %t\n", fastly.ToValue(d.WriteOnly)) + fmt.Fprintf(out, "Created (UTC): %s\n", d.CreatedAt.UTC().Format(time.Format)) + fmt.Fprintf(out, "Last edited (UTC): %s\n", d.UpdatedAt.UTC().Format(time.Format)) if d.DeletedAt != nil { - fmt.Fprintf(out, "Deleted (UTC): %s\n", d.DeletedAt.UTC().Format(common.TimeFormat)) + fmt.Fprintf(out, "Deleted (UTC): %s\n", d.DeletedAt.UTC().Format(time.Format)) } } diff --git a/pkg/text/dictionaryitem.go b/pkg/text/dictionaryitem.go index b61db6506..255666633 100644 --- a/pkg/text/dictionaryitem.go +++ b/pkg/text/dictionaryitem.go @@ -4,9 +4,11 @@ import ( "fmt" "io" - "github.com/fastly/cli/pkg/common" - "github.com/fastly/go-fastly/v3/fastly" "github.com/segmentio/textio" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/time" ) // PrintDictionaryItem pretty prints a fastly.DictionaryInfo structure in verbose @@ -15,23 +17,23 @@ import ( func PrintDictionaryItem(out io.Writer, prefix string, d *fastly.DictionaryItem) { out = textio.NewPrefixWriter(out, prefix) - fmt.Fprintf(out, "Dictionary ID: %s\n", d.DictionaryID) - fmt.Fprintf(out, "Item Key: %s\n", d.ItemKey) - fmt.Fprintf(out, "Item Value: %s\n", d.ItemValue) + fmt.Fprintf(out, "Dictionary ID: %s\n", fastly.ToValue(d.DictionaryID)) + fmt.Fprintf(out, "Item Key: %s\n", fastly.ToValue(d.ItemKey)) + fmt.Fprintf(out, "Item Value: %s\n", fastly.ToValue(d.ItemValue)) if d.CreatedAt != nil { - fmt.Fprintf(out, "Created (UTC): %s\n", d.CreatedAt.UTC().Format(common.TimeFormat)) + fmt.Fprintf(out, "Created (UTC): %s\n", d.CreatedAt.UTC().Format(time.Format)) } if d.UpdatedAt != nil { - fmt.Fprintf(out, "Last edited (UTC): %s\n", d.UpdatedAt.UTC().Format(common.TimeFormat)) + fmt.Fprintf(out, "Last edited (UTC): %s\n", d.UpdatedAt.UTC().Format(time.Format)) } if d.DeletedAt != nil { - fmt.Fprintf(out, "Deleted (UTC): %s\n", d.DeletedAt.UTC().Format(common.TimeFormat)) + fmt.Fprintf(out, "Deleted (UTC): %s\n", d.DeletedAt.UTC().Format(time.Format)) } } // PrintDictionaryItemKV pretty prints only the key/value pairs from a dictionary item. func PrintDictionaryItemKV(out io.Writer, prefix string, d *fastly.DictionaryItem) { out = textio.NewPrefixWriter(out, prefix) - fmt.Fprintf(out, "Item Key: %s\n", d.ItemKey) - fmt.Fprintf(out, "Item Value: %s\n", d.ItemValue) + fmt.Fprintf(out, "Item Key: %s\n", fastly.ToValue(d.ItemKey)) + fmt.Fprintf(out, "Item Value: %s\n", fastly.ToValue(d.ItemValue)) } diff --git a/pkg/text/dictionaryitem_test.go b/pkg/text/dictionaryitem_test.go index 88e0529ec..1dfe9b41f 100644 --- a/pkg/text/dictionaryitem_test.go +++ b/pkg/text/dictionaryitem_test.go @@ -6,7 +6,7 @@ import ( "github.com/fastly/cli/pkg/testutil" "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" + "github.com/fastly/go-fastly/v10/fastly" ) func TestPrintDictionaryItem(t *testing.T) { diff --git a/pkg/text/doc.go b/pkg/text/doc.go new file mode 100644 index 000000000..2140db2b3 --- /dev/null +++ b/pkg/text/doc.go @@ -0,0 +1,2 @@ +// Package text contains functions for handling the display of text. +package text diff --git a/pkg/text/healthcheck.go b/pkg/text/healthcheck.go index 127368eae..55a2d0f7b 100644 --- a/pkg/text/healthcheck.go +++ b/pkg/text/healthcheck.go @@ -4,8 +4,9 @@ import ( "fmt" "io" - "github.com/fastly/go-fastly/v3/fastly" "github.com/segmentio/textio" + + "github.com/fastly/go-fastly/v10/fastly" ) // PrintHealthCheck pretty prints a fastly.HealthCheck structure in verbose @@ -14,16 +15,16 @@ import ( func PrintHealthCheck(out io.Writer, prefix string, h *fastly.HealthCheck) { out = textio.NewPrefixWriter(out, prefix) - fmt.Fprintf(out, "Name: %s\n", h.Name) - fmt.Fprintf(out, "Comment: %s\n", h.Comment) - fmt.Fprintf(out, "Method: %s\n", h.Method) - fmt.Fprintf(out, "Host: %s\n", h.Host) - fmt.Fprintf(out, "Path: %s\n", h.Path) - fmt.Fprintf(out, "HTTP version: %s\n", h.HTTPVersion) - fmt.Fprintf(out, "Timeout: %d\n", h.Timeout) - fmt.Fprintf(out, "Check interval: %d\n", h.CheckInterval) - fmt.Fprintf(out, "Expected response: %d\n", h.ExpectedResponse) - fmt.Fprintf(out, "Window: %d\n", h.Window) - fmt.Fprintf(out, "Threshold: %d\n", h.Threshold) - fmt.Fprintf(out, "Initial: %d\n", h.Initial) + fmt.Fprintf(out, "Name: %s\n", fastly.ToValue(h.Name)) + fmt.Fprintf(out, "Comment: %s\n", fastly.ToValue(h.Comment)) + fmt.Fprintf(out, "Method: %s\n", fastly.ToValue(h.Method)) + fmt.Fprintf(out, "Host: %s\n", fastly.ToValue(h.Host)) + fmt.Fprintf(out, "Path: %s\n", fastly.ToValue(h.Path)) + fmt.Fprintf(out, "HTTP version: %s\n", fastly.ToValue(h.HTTPVersion)) + fmt.Fprintf(out, "Timeout: %d\n", fastly.ToValue(h.Timeout)) + fmt.Fprintf(out, "Check interval: %d\n", fastly.ToValue(h.CheckInterval)) + fmt.Fprintf(out, "Expected response: %d\n", fastly.ToValue(h.ExpectedResponse)) + fmt.Fprintf(out, "Window: %d\n", fastly.ToValue(h.Window)) + fmt.Fprintf(out, "Threshold: %d\n", fastly.ToValue(h.Threshold)) + fmt.Fprintf(out, "Initial: %d\n", fastly.ToValue(h.Initial)) } diff --git a/pkg/text/kvstore.go b/pkg/text/kvstore.go new file mode 100644 index 000000000..b26ed0859 --- /dev/null +++ b/pkg/text/kvstore.go @@ -0,0 +1,45 @@ +package text + +import ( + "fmt" + "io" + + "github.com/segmentio/textio" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/time" +) + +// PrintKVStore pretty prints a fastly.Dictionary structure in verbose +// format to a given io.Writer. Consumers can provide a prefix string which +// will be used as a prefix to each line, useful for indentation. +func PrintKVStore(out io.Writer, prefix string, k *fastly.KVStore) { + out = textio.NewPrefixWriter(out, prefix) + + fmt.Fprintf(out, "\nID: %s\n", k.StoreID) + fmt.Fprintf(out, "Name: %s\n", k.Name) + fmt.Fprintf(out, "Created (UTC): %s\n", k.CreatedAt.UTC().Format(time.Format)) + fmt.Fprintf(out, "Last edited (UTC): %s\n", k.UpdatedAt.UTC().Format(time.Format)) +} + +// PrintKVStoreKeys pretty prints a list of kv store keys in verbose +// format to a given io.Writer. Consumers can provide a prefix string which +// will be used as a prefix to each line, useful for indentation. +func PrintKVStoreKeys(out io.Writer, prefix string, keys []string) { + out = textio.NewPrefixWriter(out, prefix) + + for _, k := range keys { + fmt.Fprintf(out, "Key: %s\n", k) + } +} + +// PrintKVStoreKeyValue pretty prints a value from an kv store to a +// given io.Writer. Consumers can provide a prefix string which will be used as +// a prefix to each line, useful for indentation. +func PrintKVStoreKeyValue(out io.Writer, prefix string, key, value string) { + out = textio.NewPrefixWriter(out, prefix) + + fmt.Fprintf(out, "Key: %s\n", key) + fmt.Fprintf(out, "Value: %q\n", value) +} diff --git a/pkg/text/lines.go b/pkg/text/lines.go new file mode 100644 index 000000000..c0531dff3 --- /dev/null +++ b/pkg/text/lines.go @@ -0,0 +1,24 @@ +package text + +import ( + "fmt" + "io" + "sort" +) + +// Lines is the struct that is used by PrintLines. +type Lines map[string]any + +// PrintLines pretty prints a Lines struct with one item per line. +// The map is sorted before printing and a newline is added at the beginning. +func PrintLines(out io.Writer, lines Lines) { + keys := make([]string, 0, len(lines)) + for k := range lines { + keys = append(keys, k) + } + sort.Strings(keys) + fmt.Fprintf(out, "\n") + for _, k := range keys { + fmt.Fprintf(out, "%s: %+v\n", k, lines[k]) + } +} diff --git a/pkg/text/lines_test.go b/pkg/text/lines_test.go new file mode 100644 index 000000000..42b0470bc --- /dev/null +++ b/pkg/text/lines_test.go @@ -0,0 +1,39 @@ +package text_test + +import ( + "bytes" + "testing" + + "github.com/fastly/cli/pkg/testutil" + "github.com/fastly/cli/pkg/text" +) + +func TestPrintLines(t *testing.T) { + for _, testcase := range []struct { + name string + mapItem text.Lines + wantOutput string + }{ + { + name: "base", + mapItem: text.Lines{"item": "value"}, + wantOutput: "\nitem: value\n", + }, + { + name: "number", + mapItem: text.Lines{"number": 2}, + wantOutput: "\nnumber: 2\n", + }, + { + name: "sort", + mapItem: text.Lines{"b": 2, "a": 1, "c": 3}, + wantOutput: "\na: 1\nb: 2\nc: 3\n", + }, + } { + t.Run(testcase.name, func(t *testing.T) { + var buf bytes.Buffer + text.PrintLines(&buf, testcase.mapItem) + testutil.AssertString(t, testcase.wantOutput, buf.String()) + }) + } +} diff --git a/pkg/text/progress.go b/pkg/text/progress.go deleted file mode 100644 index 1f713d99b..000000000 --- a/pkg/text/progress.go +++ /dev/null @@ -1,266 +0,0 @@ -package text - -import ( - "bytes" - "context" - "encoding/hex" - "fmt" - "io" - "runtime" - "strings" - "sync" - "time" - "unicode/utf8" -) - -// Progress is a producer contract, abstracting over the quiet and verbose -// Progress types. Consumers may use a Progress value in their code, and assign -// it based on the presence of a -v, --verbose flag. Callers are expected to -// call Step for each new major step of their procedural code, and Write with -// the verbose or detailed output of those steps. Callers must eventually call -// either Done or Fail, to signal success or failure respectively. -type Progress interface { - io.Writer - Tick(rune) - Step(string) - Done() - Fail() -} - -// Ticker is a small consumer contract for the Spin function, -// capturing part of the Progress interface. -type Ticker interface { - Tick(r rune) -} - -// Spin calls Tick on the target with the relevant frame every interval. It -// returns when context is canceled, so should be called in its own goroutine. -func Spin(ctx context.Context, frames []rune, interval time.Duration, target Ticker) error { - var ( - cursor = 0 - ticker = time.NewTicker(interval) - ) - defer ticker.Stop() - for { - select { - case <-ticker.C: - target.Tick(frames[cursor]) - cursor = (cursor + 1) % len(frames) - case <-ctx.Done(): - return ctx.Err() - } - } -} - -// QuietProgress is an implementation of Progress that includes a spinner at the -// beginning of each Step, and where newline-delimited lines written via Write -// overwrite the current step line in the output. -type QuietProgress struct { - mtx sync.Mutex - output io.Writer - - stepHeader string // title of current step - writeBuffer bytes.Buffer // receives Write calls - lastBufferLine string // last full line in writeBuffer - currentOutput string // the content of the current line displayed to user - - cancel func() // tell Spin to stop - done <-chan struct{} // wait for Spin to stop -} - -// NewQuietProgress returns a QuietProgress outputting to the writer. -func NewQuietProgress(output io.Writer) *QuietProgress { - p := &QuietProgress{ - output: output, - stepHeader: "Initializing...", - } - - var ( - ctx, cancel = context.WithCancel(context.Background()) - done = make(chan struct{}) - ) - go func() { - Spin(ctx, []rune{'-', '\\', '|', '/'}, 100*time.Millisecond, p) - close(done) - }() - p.cancel = cancel - p.done = done - - return p -} - -func (p *QuietProgress) replaceLine(format string, args ...interface{}) { - // Clear the current line. - n := utf8.RuneCountInString(p.currentOutput) - switch runtime.GOOS { - case "windows": - fmt.Fprintf(p.output, "%s\r", strings.Repeat(" ", n)) - default: - del, _ := hex.DecodeString("7f") - sequence := fmt.Sprintf("\b%s\b\033[K", del) - fmt.Fprintf(p.output, "%s\r", strings.Repeat(sequence, n)) - } - - // Generate the new line. - s := fmt.Sprintf(format, args...) - p.currentOutput = s - fmt.Fprint(p.output, p.currentOutput) -} - -func (p *QuietProgress) getStatus() string { - if p.lastBufferLine != "" { - return p.lastBufferLine // takes precedence - } - return p.stepHeader -} - -// Tick implements the Progress interface. -func (p *QuietProgress) Tick(r rune) { - p.mtx.Lock() - defer p.mtx.Unlock() - - p.replaceLine("%s %s", string(r), p.getStatus()) -} - -// Write implements the Progress interface, emitting each incoming byte slice -// to the internal buffer to be written to the terminal on the next tick. -func (p *QuietProgress) Write(buf []byte) (int, error) { - p.mtx.Lock() - defer p.mtx.Unlock() - - p.writeBuffer.Write(buf) - p.lastBufferLine = LastFullLine(p.writeBuffer.String()) - - return len(buf), nil -} - -// Step implements the Progress interface. -func (p *QuietProgress) Step(msg string) { - msg = strings.TrimSpace(msg) - - p.mtx.Lock() - defer p.mtx.Unlock() - - // Previous step complete. - p.replaceLine("%s %s", Bold("✓"), p.stepHeader) - fmt.Fprintln(p.output) - - // Reset all the stepwise state. - p.stepHeader = msg - p.writeBuffer.Reset() - p.lastBufferLine = "" - p.currentOutput = "" - - // New step beginning. - p.replaceLine("%s %s", Bold("·"), p.stepHeader) -} - -// Done implements the Progress interface. -func (p *QuietProgress) Done() { - // It's important to cancel the Spin goroutine before taking the lock, - // because otherwise it's possible to generate a deadlock if the output - // io.Writer is also synchronized. - p.cancel() - <-p.done - - p.mtx.Lock() - defer p.mtx.Unlock() - - p.replaceLine("%s %s", Bold("✓"), p.stepHeader) - fmt.Fprintln(p.output) -} - -// Fail implements the Progress interface. -func (p *QuietProgress) Fail() { - p.cancel() - <-p.done - - p.mtx.Lock() - defer p.mtx.Unlock() - - p.replaceLine("%s %s", Bold("✗"), p.stepHeader) - fmt.Fprintln(p.output) -} - -// LastFullLine returns the last full \n delimited line in s. That is, s must -// contain at least one \n for LastFullLine to return anything. -func LastFullLine(s string) string { - last := strings.LastIndex(s, "\n") - if last < 0 { - return "" - } - - prev := strings.LastIndex(s[:last], "\n") - if prev < 0 { - prev = 0 - } - - return strings.TrimSpace(s[prev:last]) -} - -// -// -// - -// VerboseProgress is an implementation of Progress that treats Step and Write -// more or less the same: it simply pipes all output to the provided Writer. No -// spinners are used. -type VerboseProgress struct { - output io.Writer -} - -// NewVerboseProgress returns a VerboseProgress outputting to the writer. -func NewVerboseProgress(output io.Writer) *VerboseProgress { - return &VerboseProgress{ - output: output, - } -} - -// Tick implements the Progress interface. It's a no-op. -func (p *VerboseProgress) Tick(r rune) {} - -// Tick implements the Progress interface. -func (p *VerboseProgress) Write(buf []byte) (int, error) { - return p.output.Write(buf) -} - -// Step implements the Progress interface. -func (p *VerboseProgress) Step(msg string) { - fmt.Fprintln(p.output, strings.TrimSpace(msg)) -} - -// Done implements the Progress interface. It's a no-op. -func (p *VerboseProgress) Done() {} - -// Fail implements the Progress interface. It's a no-op. -func (p *VerboseProgress) Fail() {} - -// -// -// - -// NullProgress is an implementation of Progress which discards everything -// written into it and produces no output. -type NullProgress struct{} - -// NewNullProgress returns a NullProgress. -func NewNullProgress() *NullProgress { - return &NullProgress{} -} - -// Tick implements the Progress interface. It's a no-op. -func (p *NullProgress) Tick(r rune) {} - -// Tick implements the Progress interface. -func (p *NullProgress) Write(buf []byte) (int, error) { - return 0, nil -} - -// Step implements the Progress interface. -func (p *NullProgress) Step(msg string) {} - -// Done implements the Progress interface. It's a no-op. -func (p *NullProgress) Done() {} - -// Fail implements the Progress interface. It's a no-op. -func (p *NullProgress) Fail() {} diff --git a/pkg/text/progress_test.go b/pkg/text/progress_test.go deleted file mode 100644 index 1c218e8c3..000000000 --- a/pkg/text/progress_test.go +++ /dev/null @@ -1,98 +0,0 @@ -package text_test - -import ( - "fmt" - "io" - "os" - "testing" - "time" - - "github.com/fastly/cli/pkg/text" -) - -func TestProgress(t *testing.T) { - for _, testcase := range []struct { - name string - constructor func(io.Writer) text.Progress - }{ - { - name: "quiet", - constructor: func(w io.Writer) text.Progress { return text.NewQuietProgress(w) }, - }, - { - name: "verbose", - constructor: func(w io.Writer) text.Progress { return text.NewVerboseProgress(w) }, - }, - } { - t.Run(testcase.name, func(t *testing.T) { - p := testcase.constructor(os.Stdout) - for _, f := range []func(){ - func() { fmt.Fprintf(p, "Alpha\n") }, - func() { p.Step("Step one...") }, - func() { fmt.Fprintf(p, "Beta\n") }, - func() { fmt.Fprintf(p, "Delta\n") }, - func() { p.Step("Step two...") }, - func() { fmt.Fprintf(p, "Iota\n") }, - func() { fmt.Fprintf(p, "Kappa\n") }, - func() { fmt.Fprintf(p, "Gamma\n") }, - func() { fmt.Fprintf(p, "Omicron\n") }, - func() { p.Step("Step three...") }, - func() { fmt.Fprintf(p, "Nü\n") }, - func() { p.Done() }, - } { - f() - time.Sleep(250 * time.Millisecond) - } - }) - } -} - -func TestLastFullLine(t *testing.T) { - for _, testcase := range []struct { - name string - input string - want string - }{ - { - name: "empty", - input: "", - want: "", - }, - { - name: "no newline", - input: "abc def ghi", - want: "", - }, - { - name: "one newline at end", - input: "abc def ghi\n", - want: "abc def ghi", - }, - { - name: "one full line and a partial", - input: "foo bar\nbaz quux", - want: "foo bar", - }, - { - name: "multiple lines", - input: "alpha beta\ndelta kappa\ngamma iota\nomicron nu", - want: "gamma iota", - }, - { - name: "multiple newlines at end", - input: "alpha beta\n\n\ndelta kappa\n\ngamma iota\n\nomicron nu\n\n", - want: "", - }, - { - name: "multiple newlines in middle", - input: "alpha beta\n\n\ndelta kappa\n\ngamma iota\n\nomicron nu", - want: "", - }, - } { - t.Run(testcase.name, func(t *testing.T) { - if want, have := testcase.want, text.LastFullLine(testcase.input); want != have { - t.Fatalf("want %q, have %q", want, have) - } - }) - } -} diff --git a/pkg/text/resource.go b/pkg/text/resource.go new file mode 100644 index 000000000..080e1b985 --- /dev/null +++ b/pkg/text/resource.go @@ -0,0 +1,39 @@ +package text + +import ( + "fmt" + "io" + + "github.com/segmentio/textio" + + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/time" +) + +// PrintResource pretty prints a fastly.Resource structure in verbose +// format to a given io.Writer. Consumers can provide a prefix string which +// will be used as a prefix to each line, useful for indentation. +func PrintResource(out io.Writer, prefix string, r *fastly.Resource) { + if r == nil { + return + } + out = textio.NewPrefixWriter(out, prefix) + + fmt.Fprintf(out, "ID: %s\n", fastly.ToValue(r.LinkID)) + fmt.Fprintf(out, "Name: %s\n", fastly.ToValue(r.Name)) + fmt.Fprintf(out, "Service ID: %s\n", fastly.ToValue(r.ServiceID)) + fmt.Fprintf(out, "Service Version: %d\n", fastly.ToValue(r.ServiceVersion)) + fmt.Fprintf(out, "Resource ID: %s\n", fastly.ToValue(r.ResourceID)) + fmt.Fprintf(out, "Resource Type: %s\n", fastly.ToValue(r.ResourceType)) + + if r.CreatedAt != nil { + fmt.Fprintf(out, "Created (UTC): %s\n", r.CreatedAt.UTC().Format(time.Format)) + } + if r.UpdatedAt != nil { + fmt.Fprintf(out, "Last edited (UTC): %s\n", r.UpdatedAt.UTC().Format(time.Format)) + } + if r.DeletedAt != nil { + fmt.Fprintf(out, "Deleted (UTC): %s\n", r.DeletedAt.UTC().Format(time.Format)) + } +} diff --git a/pkg/text/secretstore.go b/pkg/text/secretstore.go new file mode 100644 index 000000000..215957628 --- /dev/null +++ b/pkg/text/secretstore.go @@ -0,0 +1,60 @@ +package text + +import ( + "encoding/hex" + "fmt" + "io" + + "github.com/segmentio/textio" + + "github.com/fastly/go-fastly/v10/fastly" +) + +// PrintSecretStoresTbl displays store data in a table format. +func PrintSecretStoresTbl(out io.Writer, stores []fastly.SecretStore) { + tbl := NewTable(out) + tbl.AddHeader("Name", "ID") + + for _, store := range stores { + tbl.AddLine(store.Name, store.StoreID) + } + tbl.Print() +} + +// PrintSecretsTbl displays secrets data in a table format. +func PrintSecretsTbl(out io.Writer, secrets *fastly.Secrets) { + tbl := NewTable(out) + tbl.AddHeader("Name", "Digest") + + if secrets == nil { + tbl.Print() + return + } + + for _, s := range secrets.Data { + // avoid gosec loop aliasing check :/ + s := s + tbl.AddLine(s.Name, hex.EncodeToString(s.Digest)) + } + tbl.Print() + + if secrets.Meta.NextCursor != "" { + fmt.Fprintf(out, "\nNext cursor: %s\n", secrets.Meta.NextCursor) + } +} + +// PrintSecretStore displays store data. +func PrintSecretStore(out io.Writer, prefix string, s *fastly.SecretStore) { + out = textio.NewPrefixWriter(out, prefix) + + fmt.Fprintf(out, "Name: %s\n", s.Name) + fmt.Fprintf(out, "ID: %s\n", s.StoreID) +} + +// PrintSecret displays store data. +func PrintSecret(out io.Writer, prefix string, s *fastly.Secret) { + out = textio.NewPrefixWriter(out, prefix) + + fmt.Fprintf(out, "Name: %s\n", s.Name) + fmt.Fprintf(out, "Digest: %s\n", hex.EncodeToString(s.Digest)) +} diff --git a/pkg/text/service.go b/pkg/text/service.go index 0abd30c81..22ed60823 100644 --- a/pkg/text/service.go +++ b/pkg/text/service.go @@ -3,23 +3,14 @@ package text import ( "fmt" "io" + "regexp" - "github.com/fastly/cli/pkg/common" - "github.com/fastly/go-fastly/v3/fastly" "github.com/segmentio/textio" -) -// ServiceType is a utility function which returns the given type string if -// non-empty otherwise returns the default `vcl`. This should be used unitl the -// API properly returns Service.Type for non-wasm services. -// TODO(phamann): remove once API returns correct type. -func ServiceType(t string) string { - st := "vcl" - if t != "" { - st = t - } - return st -} + "github.com/fastly/go-fastly/v10/fastly" + + "github.com/fastly/cli/pkg/time" +) // PrintService pretty prints a fastly.Service structure in verbose format // to a given io.Writer. Consumers can provide a prefix string which will @@ -27,65 +18,32 @@ func ServiceType(t string) string { func PrintService(out io.Writer, prefix string, s *fastly.Service) { out = textio.NewPrefixWriter(out, prefix) - fmt.Fprintf(out, "ID: %s\n", s.ID) - fmt.Fprintf(out, "Name: %s\n", s.Name) - fmt.Fprintf(out, "Type: %s\n", ServiceType(s.Type)) - if s.Comment != "" { - fmt.Fprintf(out, "Comment: %s\n", s.Comment) + if s.ServiceID != nil { + fmt.Fprintf(out, "ID: %s\n", fastly.ToValue(s.ServiceID)) } - fmt.Fprintf(out, "Customer ID: %s\n", s.CustomerID) - if s.CreatedAt != nil { - fmt.Fprintf(out, "Created (UTC): %s\n", s.CreatedAt.UTC().Format(common.TimeFormat)) + if s.Name != nil { + fmt.Fprintf(out, "Name: %s\n", fastly.ToValue(s.Name)) } - if s.UpdatedAt != nil { - fmt.Fprintf(out, "Last edited (UTC): %s\n", s.UpdatedAt.UTC().Format(common.TimeFormat)) + if s.Type != nil { + fmt.Fprintf(out, "Type: %s\n", fastly.ToValue(s.Type)) } - if s.DeletedAt != nil { - fmt.Fprintf(out, "Deleted (UTC): %s\n", s.DeletedAt.UTC().Format(common.TimeFormat)) + if s.Comment != nil { + fmt.Fprintf(out, "Comment: %s\n", fastly.ToValue(s.Comment)) } - fmt.Fprintf(out, "Active version: %d\n", s.ActiveVersion) - fmt.Fprintf(out, "Versions: %d\n", len(s.Versions)) - for j, version := range s.Versions { - fmt.Fprintf(out, "\tVersion %d/%d\n", j+1, len(s.Versions)) - PrintVersion(out, "\t\t", version) + if s.CustomerID != nil { + fmt.Fprintf(out, "Customer ID: %s\n", fastly.ToValue(s.CustomerID)) } -} - -// PrintServiceDetail pretty prints a fastly.ServiceDetail structure in verbose -// format to a given io.Writer. Consumers can provide a prefix string which -// will be used as a prefix to each line, useful for indentation. -func PrintServiceDetail(out io.Writer, indent string, s *fastly.ServiceDetail) { - out = textio.NewPrefixWriter(out, indent) - - // Initally services have no active version, however go-fastly still - // returns an empty Version struct with nil values. Which isn't useful for - // output rendering. - activeVersion := "none" - if s.ActiveVersion.Active { - activeVersion = fmt.Sprintf("%d", s.ActiveVersion.Number) - } - - fmt.Fprintf(out, "ID: %s\n", s.ID) - fmt.Fprintf(out, "Name: %s\n", s.Name) - fmt.Fprintf(out, "Type: %s\n", ServiceType(s.Type)) - if s.Comment != "" { - fmt.Fprintf(out, "Comment: %s\n", s.Comment) - } - fmt.Fprintf(out, "Customer ID: %s\n", s.CustomerID) if s.CreatedAt != nil { - fmt.Fprintf(out, "Created (UTC): %s\n", s.CreatedAt.UTC().Format(common.TimeFormat)) + fmt.Fprintf(out, "Created (UTC): %s\n", s.CreatedAt.UTC().Format(time.Format)) } if s.UpdatedAt != nil { - fmt.Fprintf(out, "Last edited (UTC): %s\n", s.UpdatedAt.UTC().Format(common.TimeFormat)) + fmt.Fprintf(out, "Last edited (UTC): %s\n", s.UpdatedAt.UTC().Format(time.Format)) } if s.DeletedAt != nil { - fmt.Fprintf(out, "Deleted (UTC): %s\n", s.DeletedAt.UTC().Format(common.TimeFormat)) + fmt.Fprintf(out, "Deleted (UTC): %s\n", s.DeletedAt.UTC().Format(time.Format)) } - if s.ActiveVersion.Active { - fmt.Fprintf(out, "Active version:\n") - PrintVersion(out, "\t", &s.ActiveVersion) - } else { - fmt.Fprintf(out, "Active version: %s\n", activeVersion) + if s.ActiveVersion != nil { + fmt.Fprintf(out, "Active version: %d\n", fastly.ToValue(s.ActiveVersion)) } fmt.Fprintf(out, "Versions: %d\n", len(s.Versions)) for j, version := range s.Versions { @@ -100,23 +58,44 @@ func PrintServiceDetail(out io.Writer, indent string, s *fastly.ServiceDetail) { func PrintVersion(out io.Writer, indent string, v *fastly.Version) { out = textio.NewPrefixWriter(out, indent) - fmt.Fprintf(out, "Number: %d\n", v.Number) - if v.Comment != "" { - fmt.Fprintf(out, "Comment: %s\n", v.Comment) - } - fmt.Fprintf(out, "Service ID: %s\n", v.ServiceID) - fmt.Fprintf(out, "Active: %v\n", v.Active) - fmt.Fprintf(out, "Locked: %v\n", v.Locked) - fmt.Fprintf(out, "Deployed: %v\n", v.Deployed) - fmt.Fprintf(out, "Staging: %v\n", v.Staging) - fmt.Fprintf(out, "Testing: %v\n", v.Testing) + if v.Number != nil { + fmt.Fprintf(out, "Number: %d\n", fastly.ToValue(v.Number)) + } + if v.Comment != nil { + fmt.Fprintf(out, "Comment: %s\n", fastly.ToValue(v.Comment)) + } + if v.ServiceID != nil { + fmt.Fprintf(out, "Service ID: %s\n", fastly.ToValue(v.ServiceID)) + } + if v.Active != nil { + fmt.Fprintf(out, "Active: %v\n", fastly.ToValue(v.Active)) + } + if v.Locked != nil { + fmt.Fprintf(out, "Locked: %v\n", fastly.ToValue(v.Locked)) + } + if v.Deployed != nil { + fmt.Fprintf(out, "Deployed: %v\n", fastly.ToValue(v.Deployed)) + } + if v.Staging != nil { + fmt.Fprintf(out, "Staged: %v\n", fastly.ToValue(v.Staging)) + } + if v.Testing != nil { + fmt.Fprintf(out, "Testing: %v\n", fastly.ToValue(v.Testing)) + } if v.CreatedAt != nil { - fmt.Fprintf(out, "Created (UTC): %s\n", v.CreatedAt.UTC().Format(common.TimeFormat)) + fmt.Fprintf(out, "Created (UTC): %s\n", v.CreatedAt.UTC().Format(time.Format)) } if v.UpdatedAt != nil { - fmt.Fprintf(out, "Last edited (UTC): %s\n", v.UpdatedAt.UTC().Format(common.TimeFormat)) + fmt.Fprintf(out, "Last edited (UTC): %s\n", v.UpdatedAt.UTC().Format(time.Format)) } if v.DeletedAt != nil { - fmt.Fprintf(out, "Deleted (UTC): %s\n", v.DeletedAt.UTC().Format(common.TimeFormat)) + fmt.Fprintf(out, "Deleted (UTC): %s\n", v.DeletedAt.UTC().Format(time.Format)) } } + +var fastlyIDRegEx = regexp.MustCompile("^[0-9a-zA-Z]{22}$") + +// IsFastlyID determines if a string looks like a Fastly ID. +func IsFastlyID(s string) bool { + return fastlyIDRegEx.Match([]byte(s)) +} diff --git a/pkg/text/service_test.go b/pkg/text/service_test.go index 95a4da14f..32f4ca136 100644 --- a/pkg/text/service_test.go +++ b/pkg/text/service_test.go @@ -4,40 +4,12 @@ import ( "bytes" "testing" + "github.com/fastly/go-fastly/v10/fastly" + "github.com/fastly/cli/pkg/testutil" "github.com/fastly/cli/pkg/text" - "github.com/fastly/go-fastly/v3/fastly" ) -func TestServiceType(t *testing.T) { - for _, testcase := range []struct { - name string - in string - wantResult string - }{ - { - name: "empty", - in: "", - wantResult: "vcl", - }, - { - name: "vcl", - in: "vcl", - wantResult: "vcl", - }, - { - name: "wasm", - in: "wasm", - wantResult: "wasm", - }, - } { - t.Run(testcase.name, func(t *testing.T) { - result := text.ServiceType(testcase.in) - testutil.AssertString(t, testcase.wantResult, result) - }) - } -} - func TestPrintService(t *testing.T) { for _, testcase := range []struct { name string @@ -46,16 +18,28 @@ func TestPrintService(t *testing.T) { wantOutput string }{ { - name: "without prefix", - prefix: "", - service: &fastly.Service{}, - wantOutput: "ID: \nName: \nType: vcl\nCustomer ID: \nActive version: 0\nVersions: 0\n", + name: "without prefix", + prefix: "", + service: &fastly.Service{ + ServiceID: fastly.ToPointer("1"), + Name: fastly.ToPointer("2"), + Type: fastly.ToPointer("3"), + CustomerID: fastly.ToPointer("4"), + ActiveVersion: fastly.ToPointer(5), + }, + wantOutput: "ID: 1\nName: 2\nType: 3\nCustomer ID: 4\nActive version: 5\nVersions: 0\n", }, { - name: "with prefix", - prefix: "\t", - service: &fastly.Service{}, - wantOutput: "\tID: \n\tName: \n\tType: vcl\n\tCustomer ID: \n\tActive version: 0\n\tVersions: 0\n", + name: "with prefix", + prefix: "\t", + service: &fastly.Service{ + ServiceID: fastly.ToPointer("1"), + Name: fastly.ToPointer("2"), + Type: fastly.ToPointer("3"), + CustomerID: fastly.ToPointer("4"), + ActiveVersion: fastly.ToPointer(5), + }, + wantOutput: "\tID: 1\n\tName: 2\n\tType: 3\n\tCustomer ID: 4\n\tActive version: 5\n\tVersions: 0\n", }, } { t.Run(testcase.name, func(t *testing.T) { @@ -66,58 +50,84 @@ func TestPrintService(t *testing.T) { } } -func TestPrintServiceDetail(t *testing.T) { +func TestPrintVersion(t *testing.T) { for _, testcase := range []struct { name string prefix string - service *fastly.ServiceDetail + version *fastly.Version wantOutput string }{ { - name: "without prefix", - prefix: "", - service: &fastly.ServiceDetail{}, - wantOutput: "ID: \nName: \nType: vcl\nCustomer ID: \nActive version: none\nVersions: 0\n", + name: "without prefix", + prefix: "", + version: &fastly.Version{ + Number: fastly.ToPointer(1), + ServiceID: fastly.ToPointer("example"), + Active: fastly.ToPointer(true), + Locked: fastly.ToPointer(true), + Deployed: fastly.ToPointer(true), + Staging: fastly.ToPointer(true), + Testing: fastly.ToPointer(false), + }, + wantOutput: "Number: 1\nService ID: example\nActive: true\nLocked: true\nDeployed: true\nStaged: true\nTesting: false\n", }, { - name: "with prefix", - prefix: "\t", - service: &fastly.ServiceDetail{}, - wantOutput: "\tID: \n\tName: \n\tType: vcl\n\tCustomer ID: \n\tActive version: none\n\tVersions: 0\n", + name: "with", + prefix: "\t", + version: &fastly.Version{ + Number: fastly.ToPointer(1), + ServiceID: fastly.ToPointer("example"), + Active: fastly.ToPointer(true), + Locked: fastly.ToPointer(true), + Deployed: fastly.ToPointer(true), + Staging: fastly.ToPointer(true), + Testing: fastly.ToPointer(false), + }, + wantOutput: "\tNumber: 1\n\tService ID: example\n\tActive: true\n\tLocked: true\n\tDeployed: true\n\tStaged: true\n\tTesting: false\n", }, } { t.Run(testcase.name, func(t *testing.T) { var buf bytes.Buffer - text.PrintServiceDetail(&buf, testcase.prefix, testcase.service) + text.PrintVersion(&buf, testcase.prefix, testcase.version) testutil.AssertString(t, testcase.wantOutput, buf.String()) }) } } -func TestPrintVersion(t *testing.T) { +func TestIsFastlyID(t *testing.T) { for _, testcase := range []struct { - name string - prefix string - version *fastly.Version - wantOutput string + name string + input string + want bool }{ { - name: "without prefix", - prefix: "", - version: &fastly.Version{}, - wantOutput: "Number: 0\nService ID: \nActive: false\nLocked: false\nDeployed: false\nStaging: false\nTesting: false\n", + name: "looks like an ID", + input: "XkblwIHmR01sOnDHxusu6a", + want: true, + }, + { + name: "looks like a URL", + input: "https://github.com/fastly/cli", + want: false, + }, + { + name: "too short", + input: "Vkzj9WNseT1XN0", + want: false, + }, + { + name: "too long", + input: "Vkzj9WNseT1XN0GqjYrgQGVkzj9WNseT1", + want: false, }, { - name: "with", - prefix: "\t", - version: &fastly.Version{}, - wantOutput: "\tNumber: 0\n\tService ID: \n\tActive: false\n\tLocked: false\n\tDeployed: false\n\tStaging: false\n\tTesting: false\n", + name: "invalid characters", + input: "GLql1:uzgoC-tEK7bdobt5", + want: false, }, } { t.Run(testcase.name, func(t *testing.T) { - var buf bytes.Buffer - text.PrintVersion(&buf, testcase.prefix, testcase.version) - testutil.AssertString(t, testcase.wantOutput, buf.String()) + testutil.AssertBool(t, testcase.want, text.IsFastlyID(testcase.input)) }) } } diff --git a/pkg/text/spinner.go b/pkg/text/spinner.go new file mode 100644 index 000000000..ca6fce74a --- /dev/null +++ b/pkg/text/spinner.go @@ -0,0 +1,82 @@ +package text + +import ( + "fmt" + "io" + "time" + + "github.com/theckman/yacspin" +) + +// SpinnerErrWrapper is a generic error message the caller can interpolate their +// own error into. +const SpinnerErrWrapper = "failed to stop spinner (error: %w) when handling the error: %w" + +// Spinner represents a terminal prompt status indicator. +type Spinner interface { + Status() yacspin.SpinnerStatus + Start() error + Message(message string) + StopFailMessage(message string) + StopFail() error + StopMessage(message string) + Stop() error + Process(msg string, fn SpinnerProcess) error +} + +// SpinnerProcess is the logic to execute in between the spinner start/stop. +// +// NOTE: The `sp` SpinnerWrapper is passed in to handle more complex scenarios. +// For example, the logic inside the SpinnerProcess might want to control the +// Start/Stop mechanisms outside of the basic flow provided by `Process()`. +type SpinnerProcess func(sp *SpinnerWrapper) error + +// SpinnerWrapper implements the Spinner interface. +type SpinnerWrapper struct { + *yacspin.Spinner + err error +} + +// Process starts/stops the spinner with `msg` and executes `fn` in between. +func (sp *SpinnerWrapper) Process(msg string, fn SpinnerProcess) error { + err := sp.Start() + if err != nil { + return err + } + sp.Message(msg + "...") + + err = fn(sp) + if err != nil { + sp.StopFailMessage(msg) + spinErr := sp.StopFail() + if spinErr != nil { + return fmt.Errorf("failed to stop spinner (error: %w) when handling the error: %w", spinErr, err) + } + return err + } + + sp.StopMessage(msg) + return sp.Stop() +} + +// NewSpinner returns a new instance of a terminal prompt spinner. +func NewSpinner(out io.Writer) (Spinner, error) { + spinner, err := yacspin.New(yacspin.Config{ + CharSet: yacspin.CharSets[9], + Frequency: 100 * time.Millisecond, + StopCharacter: "✓", + StopColors: []string{"fgGreen"}, + StopFailCharacter: "✗", + StopFailColors: []string{"fgRed"}, + Suffix: " ", + Writer: out, + }) + if err != nil { + return nil, err + } + + return &SpinnerWrapper{ + Spinner: spinner, + err: nil, + }, nil +} diff --git a/pkg/text/table.go b/pkg/text/table.go index a891a3796..c2a8244e3 100644 --- a/pkg/text/table.go +++ b/pkg/text/table.go @@ -18,7 +18,7 @@ type Table struct { writer *tabwriter.Writer } -// NewTable contructs a new Table. +// NewTable constructs a new Table. func NewTable(w io.Writer) *Table { return &Table{ writer: tabwriter.NewWriter(w, 0, 2, 2, ' ', 0), @@ -26,32 +26,32 @@ func NewTable(w io.Writer) *Table { } // AddLine writes a new row to the table. -func (t *Table) AddLine(args ...interface{}) { +func (t *Table) AddLine(args ...any) { var b strings.Builder for i := range args { - b.WriteString(lineStyle(`%v`)) + _, _ = b.WriteString(lineStyle(`%v`)) if i+1 != len(args) { - b.WriteString("\t") + _, _ = b.WriteString("\t") } } - b.WriteString("\n") + _, _ = b.WriteString("\n") fmt.Fprintf(t.writer, b.String(), args...) } // AddHeader writes a table header line. -func (t *Table) AddHeader(args ...interface{}) { +func (t *Table) AddHeader(args ...any) { var b strings.Builder for i := range args { - b.WriteString(headerStyle(`%s`)) + _, _ = b.WriteString(headerStyle(`%s`)) if i+1 != len(args) { - b.WriteString("\t") + _, _ = b.WriteString("\t") } } - b.WriteString("\n") + _, _ = b.WriteString("\n") fmt.Fprintf(t.writer, b.String(), args...) } // Print writes the table to the writer. func (t *Table) Print() { - t.writer.Flush() + _ = t.writer.Flush() } diff --git a/pkg/text/text.go b/pkg/text/text.go index b8a125143..2562de40e 100644 --- a/pkg/text/text.go +++ b/pkg/text/text.go @@ -9,12 +9,14 @@ import ( "syscall" "github.com/mitchellh/go-wordwrap" - "golang.org/x/crypto/ssh/terminal" + "golang.org/x/term" + + "github.com/fastly/cli/pkg/sync" ) // DefaultTextWidth is the width that should be passed to Wrap for most // general-purpose blocks of text intended for the user. -const DefaultTextWidth = 90 +const DefaultTextWidth = 120 // Wrap a string at word boundaries with a maximum line length of width. Each // newline-delimited line in the text is trimmed of whitespace before being @@ -22,22 +24,21 @@ const DefaultTextWidth = 90 // source code with whatever leading indentation looks best in context. For // example, // -// Wrap(` -// Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do -// eiusmod tempor incididunt ut labore et dolore magna aliqua. Dolor -// sed viverra ipsum nunc aliquet bibendum enim. In massa tempor nec -// feugiat. -// `, 40) +// Wrap(` +// Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do +// eiusmod tempor incididunt ut labore et dolore magna aliqua. Dolor +// sed viverra ipsum nunc aliquet bibendum enim. In massa tempor nec +// feugiat. +// `, 40) // // Produces the output string // -// Lorem ipsum dolor sit amet, consectetur -// adipiscing elit, sed do eiusmod tempor -// incididunt ut labore et dolore magna -// aliqua. Dolor sed viverra ipsum nunc -// aliquet bibendum enim. In massa tempor -// nec feugiat. -// +// Lorem ipsum dolor sit amet, consectetur +// adipiscing elit, sed do eiusmod tempor +// incididunt ut labore et dolore magna +// aliqua. Dolor sed viverra ipsum nunc +// aliquet bibendum enim. In massa tempor +// nec feugiat. func Wrap(text string, width uint) string { var b strings.Builder s := bufio.NewScanner(strings.NewReader(text)) @@ -46,17 +47,44 @@ func Wrap(text string, width uint) string { if line == "" { continue } - b.WriteString(line + " ") + _, _ = b.WriteString(line + " ") } return wordwrap.WrapString(strings.TrimSpace(b.String()), width) } +// WrapIndent a string at word boundaries with a maximum line length of width +// and indenting the lines by a specified number of spaces. +func WrapIndent(s string, limit uint, indent uint) string { + limit -= indent + wrapped := wordwrap.WrapString(s, limit) + var result []string + for _, line := range strings.Split(wrapped, "\n") { + // gosec G115 complains about this uint->int cast, but we + // know that it is safe here because the valid values + // for 'indent' are too small to cause an overflow + // #nosec G115 + result = append(result, strings.Repeat(" ", int(indent))+line) + } + return strings.Join(result, "\n") +} + +// Indent writes the help text to the writer using WrapIndent with +// DefaultTextWidth, suffixed by a newlines. It's intended to be used to provide +// detailed information, context, or help to the user. +func Indent(w io.Writer, indent uint, format string, args ...any) { + text := fmt.Sprintf(format, args...) + fmt.Fprintf(w, "%s\n", WrapIndent(text, DefaultTextWidth, indent)) +} + // Output writes the help text to the writer using Wrap with DefaultTextWidth, -// suffixed by a newlines. It's intended to be used to provide detailed +// suffixed by a newline. It's intended to be used to provide detailed // information, context, or help to the user. -func Output(w io.Writer, format string, args ...interface{}) { - text := fmt.Sprintf(format, args...) - fmt.Fprintf(w, "%s\n", Wrap(text, DefaultTextWidth)) +func Output(w io.Writer, format string, args ...any) { + prefix, suffix, txt := ParseBreaks(format) + if suffix == 0 { + suffix++ + } + fmt.Fprintf(w, strings.Repeat("\n", prefix)+Wrap(txt, DefaultTextWidth)+strings.Repeat("\n", suffix), args...) } // Input prints the prefix to the writer, and then reads a single line from the @@ -88,20 +116,48 @@ outer: } } +// IsStdin returns true if r is standard input. +func IsStdin(r io.Reader) bool { + if f, ok := r.(*os.File); ok { + return f.Fd() == uintptr(syscall.Stdin) + } + return false +} + +// IsTTY returns true if fd is a terminal. When used in combination +// with IsStdin, it can be used to determine whether standard input +// is being piped data (i.e. IsStdin == true && IsTTY == false). +// Provide STDOUT as a way to determine whether formatting and/or +// prompting is acceptable output. +func IsTTY(fd any) bool { + if s, ok := fd.(*sync.Writer); ok { + // STDOUT is commonly wrapped in a sync.Writer, so here + // we unwrap it to gain access to the underlying Writer/STDOUT. + fd = s.W + } + if f, ok := fd.(*os.File); ok { + return term.IsTerminal(int(f.Fd())) + } + return false +} + // InputSecure is like Input but doesn't echo input back to the terminal, // if and only if r is os.Stdin. func InputSecure(w io.Writer, prefix string, r io.Reader, validators ...func(string) error) (string, error) { - var ( - f, ok = r.(*os.File) - isStdin = ok && uintptr(f.Fd()) == uintptr(syscall.Stdin) - ) - if !isStdin { + if !IsStdin(r) { return Input(w, prefix, r, validators...) } read := func() (string, error) { fmt.Fprint(w, Bold(prefix)) - p, err := terminal.ReadPassword(int(syscall.Stdin)) + // IMPORTANT: Windows will fail if you remove the `int()` conversion. + // + // cannot use syscall.Stdin (variable of type syscall.Handle) as int value in argument to term.ReadPassword) + // + // This is because on *nix systems syscall.Stdin is already an int. + // But on Windows it's a Handle type: + // https://github.com/golang/go/blob/8d2eb290f83bca7d3b5154c6a7b3ac7546df5e8a/src/syscall/syscall_windows.go#L522 + p, err := term.ReadPassword(int(syscall.Stdin)) //nolint:unconvert if err != nil { return "", err } @@ -127,43 +183,128 @@ outer: } } +// AskYesNo is similar to Input, but the line read is coerced to +// one of true (yes and its variants) or false (no, its variants and +// anything else) on success. +func AskYesNo(w io.Writer, prompt string, r io.Reader) (bool, error) { + answer, err := Input(w, Prompt(prompt), r) + if err != nil { + return false, fmt.Errorf("error reading input %w", err) + } + answer = strings.ToLower(answer) + if answer == "y" || answer == "yes" { + return true, nil + } + return false, nil +} + // Break simply writes a newline to the writer. It's intended to be used between // blocks of text that would otherwise be adjacent, a sort of semantic markup. func Break(w io.Writer) { fmt.Fprintln(w) } +// BreakN writes n newlines to the writer. It's intended to be used between +// blocks of text that would otherwise be adjacent, a sort of semantic markup. +func BreakN(w io.Writer, n int) { + if n == 0 { + return + } + for i := 1; i <= n; i++ { + fmt.Fprintln(w) + } +} + +// Deprecated is a wrapper for fmt.Fprintf with a bold red "DEPRECATED: " prefix. +func Deprecated(w io.Writer, format string, args ...any) { + prefix, suffix, txt := ParseBreaks(format) + if suffix == 0 { + suffix++ + } + fmt.Fprintf(w, WrapString(BoldRed, "DEPRECATED", txt, prefix, suffix), args...) +} + // Error is a wrapper for fmt.Fprintf with a bold red "ERROR: " prefix. -func Error(w io.Writer, format string, args ...interface{}) { - format = strings.TrimRight(format, "\r\n") + "\n" - fmt.Fprintf(w, "\n"+BoldRed("ERROR: ")+format, args...) +func Error(w io.Writer, format string, args ...any) { + prefix, suffix, txt := ParseBreaks(format) + if suffix == 0 { + suffix++ + } + fmt.Fprintf(w, WrapString(BoldRed, "ERROR", txt, prefix, suffix), args...) } -// Warning is a wrapper for fmt.Fprintf with a bold yellow "WARNING: " prefix. -func Warning(w io.Writer, format string, args ...interface{}) { - format = strings.TrimRight(format, "\r\n") + "\n" - fmt.Fprintf(w, "\n"+BoldYellow("WARNING: ")+format, args...) +// Important is a wrapper for fmt.Fprintf with a bold yellow "IMPORTANT: " prefix. +func Important(w io.Writer, format string, args ...any) { + prefix, suffix, txt := ParseBreaks(format) + if suffix == 0 { + suffix++ + } + fmt.Fprintf(w, WrapString(BoldYellow, "IMPORTANT", txt, prefix, suffix), args...) } // Info is a wrapper for fmt.Fprintf with a bold "INFO: " prefix. -func Info(w io.Writer, format string, args ...interface{}) { - format = strings.TrimRight(format, "\r\n") + "\n" - fmt.Fprintf(w, "\n"+Bold("INFO: ")+format, args...) +func Info(w io.Writer, format string, args ...any) { + prefix, suffix, txt := ParseBreaks(format) + if suffix == 0 { + suffix++ + } + fmt.Fprintf(w, WrapString(BoldCyan, "INFO", txt, prefix, suffix), args...) } // Success is a wrapper for fmt.Fprintf with a bold green "SUCCESS: " prefix. -func Success(w io.Writer, format string, args ...interface{}) { - format = strings.TrimRight(format, "\r\n") + "\n" - fmt.Fprintf(w, "\n"+BoldGreen("SUCCESS: ")+format, args...) +func Success(w io.Writer, format string, args ...any) { + prefix, suffix, txt := ParseBreaks(format) + if suffix == 0 { + suffix++ + } + fmt.Fprintf(w, WrapString(BoldGreen, "SUCCESS", txt, prefix, suffix), args...) +} + +// Warning is a wrapper for fmt.Fprintf with a bold yellow "WARNING: " prefix. +func Warning(w io.Writer, format string, args ...any) { + prefix, suffix, txt := ParseBreaks(format) + if suffix == 0 { + suffix++ + } + fmt.Fprintf(w, WrapString(BoldYellow, "WARNING", txt, prefix, suffix), args...) +} + +// WrapString produces string with correct wrapping and prefix/suffix linebreaks. +func WrapString(fn ColorFn, msg, txt string, prefix, suffix int) string { + msg = fmt.Sprintf("%s: ", msg) + return strings.Repeat("\n", prefix) + Wrap(fn(msg)+txt, DefaultTextWidth) + strings.Repeat("\n", suffix) } // Description formats the output of a description item. A description item -// consists of a `term` and a `description`. Emphasis is placed on the +// consists of a `intro` and a `description`. Emphasis is placed on the // `description` using Bold(). For example: // -// To compile the package, run: -// fastly compute build +// To compile the package, run: +// fastly compute build +func Description(w io.Writer, intro, description string) { + fmt.Fprintf(w, "%s:\n\t%s\n\n", intro, Bold(description)) +} + +// ParseBreaks returns the linebreak count at the start/end of the input. // -func Description(w io.Writer, term, description string) { - fmt.Fprintf(w, "%s:\n\t%s\n\n", term, Bold(description)) +// NOTE: Any line breaks inside the main text will be stripped. +func ParseBreaks(input string) (prefix, suffix int, txt string) { + var ( + incrementSuffix bool + txts []string + ) + parts := strings.Split(input, "\n") + for _, p := range parts { + if p == "" && !incrementSuffix { + prefix++ + continue + } + incrementSuffix = true + if p == "" { + suffix++ + } else { + txts = append(txts, p) + } + } + return prefix, suffix, strings.Join(txts, " ") } diff --git a/pkg/text/text_test.go b/pkg/text/text_test.go index d0d2d3724..bcb073bbb 100644 --- a/pkg/text/text_test.go +++ b/pkg/text/text_test.go @@ -4,12 +4,14 @@ import ( "bytes" "errors" "io" + "strconv" "strings" "testing" + "github.com/google/go-cmp/cmp" + "github.com/fastly/cli/pkg/testutil" "github.com/fastly/cli/pkg/text" - "github.com/google/go-cmp/cmp" ) func TestInput(t *testing.T) { @@ -74,27 +76,108 @@ func TestInput(t *testing.T) { } } +func TestAskYesNo(t *testing.T) { + for _, testcase := range []struct { + name string + in string + wantResult bool + }{ + { + name: "empty", + in: "\n", + wantResult: false, + }, + { + name: "uppercase y", + in: "Y\n", + wantResult: true, + }, + { + name: "lowercase y", + in: "y\n", + wantResult: true, + }, + { + name: "mixed case yes", + in: "yEs\n", + wantResult: true, + }, + { + name: "mixed case no", + in: "nO\n", + wantResult: false, + }, + { + name: "anything else", + in: "whatever\n", + wantResult: false, + }, + } { + t.Run(testcase.name, func(t *testing.T) { + var buf bytes.Buffer + result, err := text.AskYesNo(&buf, "", strings.NewReader(testcase.in)) + testutil.AssertNoError(t, err) + testutil.AssertBool(t, testcase.wantResult, result) + }) + } +} + func TestPrefixes(t *testing.T) { for _, testcase := range []struct { name string - f func(io.Writer, string, ...interface{}) + f func(io.Writer, string, ...any) format string - args []interface{} + args []any want string }{ + { + name: "Deprecated", + f: text.Deprecated, + format: "Test string %d.", + args: []any{123}, + want: "DEPRECATED: Test string 123.\n", + }, { name: "Error", f: text.Error, format: "Test string %d.", - args: []interface{}{123}, - want: "\nERROR: Test string 123.\n", + args: []any{123}, + want: "ERROR: Test string 123.\n", + }, + { + name: "Important", + f: text.Important, + format: "Test string %d.", + args: []any{123}, + want: "IMPORTANT: Test string 123.\n", + }, + { + name: "Info", + f: text.Info, + format: "Test string %d.", + args: []any{123}, + want: "INFO: Test string 123.\n", }, { name: "Success", f: text.Success, format: "%s %q %d.", - args: []interface{}{"Good", "job", 99}, - want: "\nSUCCESS: Good \"job\" 99.\n", + args: []any{"Good", "job", 99}, + want: "SUCCESS: Good \"job\" 99.\n", + }, + { + name: "Warning", + f: text.Warning, + format: "\nTest string %d.\n\n", // notice inline line breaks override the default single suffix line break + args: []any{123}, + want: "\nWARNING: Test string 123.\n\n", + }, + { + name: "Info with irregular line breaks and tabs placement", + f: text.Info, + format: "\n\nTest string\n\t%s", + args: []any{"anything"}, + want: "\n\nINFO: Test string \tanything\n", }, } { t.Run(testcase.name, func(t *testing.T) { @@ -106,3 +189,125 @@ func TestPrefixes(t *testing.T) { }) } } + +func TestWrap(t *testing.T) { + for i, testcase := range []struct { + text, want string + limit uint + }{ + { + text: "Example text goes here.", + limit: 2, + want: "Example\ntext\ngoes\nhere.", // notice it won't split individual words + }, + { + text: "Example text goes here.", + limit: 12, + want: "Example text\ngoes here.", + }, + { + text: "Example text goes here.", + limit: 100, + want: "Example text goes here.", + }, + } { + t.Run(strconv.Itoa(i), func(t *testing.T) { + output := text.Wrap(testcase.text, testcase.limit) + if want, have := testcase.want, output; want != have { + t.Error(cmp.Diff(want, have)) + } + }) + } +} + +func TestWrapIndent(t *testing.T) { + for i, testcase := range []struct { + text, want string + limit, indent uint // internally limit subtracts the indent + }{ + { + text: "Example text goes here.", + limit: 2, + indent: 2, + want: " Example text goes here.", // indent causes limit to become zero so we effectively just get an indent. + }, + { + text: "Example text goes here.", + limit: 20, + indent: 4, + want: " Example text\n goes here.", + }, + { + text: "Example text goes here.", + limit: 100, + indent: 6, + want: " Example text goes here.", + }, + } { + t.Run(strconv.Itoa(i), func(t *testing.T) { + output := text.WrapIndent(testcase.text, testcase.limit, testcase.indent) + if want, have := testcase.want, output; want != have { + t.Error(cmp.Diff(want, have)) + } + }) + } +} + +func TestParseBreaks(t *testing.T) { + for _, testcase := range []struct { + name string + in string + prefix int + suffix int + txt string + }{ + { + name: "no line breaks", + in: "example", + prefix: 0, + suffix: 0, + txt: "example", + }, + { + name: "starting line breaks", + in: "\n\n\nexample", + prefix: 3, + suffix: 0, + txt: "example", + }, + { + name: "ending line breaks", + in: "example\n\n\n", + prefix: 0, + suffix: 3, + txt: "example", + }, + { + name: "both ends line breaks", + in: "\n\nexample\n\n\n", + prefix: 2, + suffix: 3, + txt: "example", + }, + { + name: "line breaks in the main text", + in: "\n\nexample message with\na line break inside\n\n\n", + prefix: 2, + suffix: 3, + txt: "example message with a line break inside", + }, + } { + t.Run(testcase.name, func(t *testing.T) { + prefix, suffix, txt := text.ParseBreaks(testcase.in) + if prefix != testcase.prefix { + t.Errorf("want: %d, have: %d", testcase.prefix, prefix) + } + if suffix != testcase.suffix { + t.Errorf("want: %d, have: %d", testcase.suffix, suffix) + } + if txt != testcase.txt { + t.Errorf("want: %s, have: %s", testcase.txt, txt) + } + }) + } +} diff --git a/pkg/threadsafe/doc.go b/pkg/threadsafe/doc.go new file mode 100644 index 000000000..27b115314 --- /dev/null +++ b/pkg/threadsafe/doc.go @@ -0,0 +1,3 @@ +// Package threadsafe contains functions and objects for handling thread-safe +// code. +package threadsafe diff --git a/pkg/threadsafe/threadsafe.go b/pkg/threadsafe/threadsafe.go new file mode 100644 index 000000000..b8d95bb32 --- /dev/null +++ b/pkg/threadsafe/threadsafe.go @@ -0,0 +1,41 @@ +package threadsafe + +import ( + "bytes" + "sync" +) + +// Buffer is a thread-safe bytes.Buffer instance. +type Buffer struct { + b bytes.Buffer + m sync.Mutex +} + +// Read reads the next len(p) bytes from the buffer. +func (b *Buffer) Read(p []byte) (n int, err error) { + b.m.Lock() + defer b.m.Unlock() + return b.b.Read(p) +} + +// Write appends the contents of p to the buffer. +func (b *Buffer) Write(p []byte) (n int, err error) { + b.m.Lock() + defer b.m.Unlock() + return b.b.Write(p) +} + +// String returns the contents of the unread portion of the buffer +// as a string. +func (b *Buffer) String() string { + b.m.Lock() + defer b.m.Unlock() + return b.b.String() +} + +// Len returns the number of bytes of the unread portion of the buffer. +func (b *Buffer) Len() int { + b.m.Lock() + defer b.m.Unlock() + return b.b.Len() +} diff --git a/pkg/time/doc.go b/pkg/time/doc.go new file mode 100644 index 000000000..905f22e6b --- /dev/null +++ b/pkg/time/doc.go @@ -0,0 +1,2 @@ +// Package time contains helper abstractions for working with time formats. +package time diff --git a/pkg/time/time.go b/pkg/time/time.go new file mode 100644 index 000000000..446097dc7 --- /dev/null +++ b/pkg/time/time.go @@ -0,0 +1,5 @@ +package time + +// Format is a format string for time.Format that reflects what the Fastly web +// UI uses. +const Format = "2006-01-02 15:04" diff --git a/pkg/undo/doc.go b/pkg/undo/doc.go new file mode 100644 index 000000000..be85e4920 --- /dev/null +++ b/pkg/undo/doc.go @@ -0,0 +1,2 @@ +// Package undo contains abstractions for working with a stack of state changes. +package undo diff --git a/pkg/undo/undo.go b/pkg/undo/undo.go new file mode 100644 index 000000000..9ceb1b083 --- /dev/null +++ b/pkg/undo/undo.go @@ -0,0 +1,85 @@ +package undo + +import ( + "fmt" + "io" + + "github.com/fastly/cli/pkg/text" +) + +// Fn is a function with no arguments which returns an error or nil. +type Fn func() error + +// Stack models a simple undo stack which consumers can use to store undo +// stateful functions, such as a function to teardown API state if something +// goes wrong during procedural commands, for example deleting a Fastly service +// after it's been created. +type Stack struct { + states []Fn +} + +// Stacker represents the API of a Stack. +type Stacker interface { + Pop() Fn + Push(elem Fn) + Len() int + RunIfError(w io.Writer, err error) +} + +// NewStack constructs a new Stack. +func NewStack() *Stack { + s := make([]Fn, 0, 1) + stack := &Stack{ + states: s, + } + return stack +} + +// Pop method pops last added Fn element off the stack and returns it. +// If stack is empty Pop() returns nil. +func (s *Stack) Pop() Fn { + n := len(s.states) + if n == 0 { + return nil + } + v := s.states[n-1] + s.states = s.states[:n-1] + return v +} + +// Push method pushes an element onto the Stack. +func (s *Stack) Push(elem Fn) { + s.states = append(s.states, elem) +} + +// Len method returns the number of elements in the Stack. +func (s *Stack) Len() int { + return len(s.states) +} + +// RunIfError unwinds the stack if a non-nil error is passed, by serially +// calling each Fn function state in FIFO order. If any Fn returns an +// error, it gets logged to the provided writer. Should be deferred, such as: +// +// undoStack := undo.NewStack() +// defer func() { undoStack.RunIfError(w, err) }() +func (s *Stack) RunIfError(w io.Writer, err error) { + if err == nil { + return + } + for i := len(s.states) - 1; i >= 0; i-- { + if err := s.states[i](); err != nil { + fmt.Fprintln(w, err) + } + } +} + +// Unwind unwinds the stack by serially calling each Fn function state in FIFO +// order. If any Fn returns an error, it gets logged to the provided writer. +func (s *Stack) Unwind(w io.Writer) { + for i := len(s.states) - 1; i >= 0; i-- { + if err := s.states[i](); err != nil { + text.Error(w, "failed to execute clean-up task: %s", err.Error()) + } + } +} diff --git a/pkg/update/check.go b/pkg/update/check.go deleted file mode 100644 index 53f577f1e..000000000 --- a/pkg/update/check.go +++ /dev/null @@ -1,76 +0,0 @@ -package update - -import ( - "context" - "fmt" - "io" - "strings" - "time" - - "github.com/blang/semver" - "github.com/fastly/cli/pkg/check" - "github.com/fastly/cli/pkg/config" -) - -// Check if the CLI can be updated. -func Check(ctx context.Context, currentVersion string, cliVersioner Versioner) (current, latest semver.Version, shouldUpdate bool, err error) { - current, err = semver.Parse(strings.TrimPrefix(currentVersion, "v")) - if err != nil { - return current, latest, false, fmt.Errorf("error reading current version: %w", err) - } - - latest, err = cliVersioner.LatestVersion(ctx) - if err != nil { - return current, latest, false, fmt.Errorf("error fetching latest version: %w", err) - } - - return current, latest, latest.GT(current), nil -} - -type checkResult struct { - current semver.Version - latest semver.Version - shouldUpdate bool - err error -} - -// CheckAsync is a helper function for Check. If the app config's LastChecked -// time has past the specified TTL, launch a goroutine to perform the Check -// using the provided context. Return a function that will print an informative -// message to the writer if there is a newer version available. -// -// Callers should invoke CheckAsync via -// -// f := CheckAsync(...) -// defer f() -// -func CheckAsync(ctx context.Context, file config.File, configFilePath string, currentVersion string, cliVersioner Versioner) (printResults func(io.Writer)) { - if !check.Stale(file.CLI.LastChecked, file.CLI.TTL) { - return func(io.Writer) {} // no-op - } - - results := make(chan checkResult, 1) - go func() { - current, latest, shouldUpdate, err := Check(ctx, currentVersion, cliVersioner) - results <- checkResult{current, latest, shouldUpdate, err} - }() - - return func(w io.Writer) { - result := <-results - if result.err == nil { - // `fastly configure` may have changed the file contents. - if err := file.Read(configFilePath); err == nil { - file.CLI.LastChecked = time.Now().Format(time.RFC3339) - file.Write(configFilePath) - } - } - if result.shouldUpdate { - fmt.Fprintf(w, "\n") - fmt.Fprintf(w, "A new version of the Fastly CLI is available.\n") - fmt.Fprintf(w, "Current version: %s\n", result.current) - fmt.Fprintf(w, "Latest version: %s\n", result.latest) - fmt.Fprintf(w, "Run `fastly update` to get the latest version.\n") - fmt.Fprintf(w, "\n") - } - } -} diff --git a/pkg/update/check_test.go b/pkg/update/check_test.go deleted file mode 100644 index 47d64e2d6..000000000 --- a/pkg/update/check_test.go +++ /dev/null @@ -1,142 +0,0 @@ -package update_test - -import ( - "bytes" - "context" - "errors" - "fmt" - "os" - "path/filepath" - "testing" - "time" - - "github.com/blang/semver" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/mock" - "github.com/fastly/cli/pkg/update" - "github.com/google/go-cmp/cmp" -) - -func TestCheck(t *testing.T) { - for _, testcase := range []struct { - name string - current string - latest update.Versioner - wantError string - wantCurrent semver.Version - wantLatest semver.Version - wantUpdate bool - }{ - { - name: "empty current version", - current: "", - latest: mock.Versioner{}, - wantError: "error reading current version: Version string empty", - }, - { - name: "invalid current version", - current: "unknown", - latest: mock.Versioner{}, - wantError: "error reading current version: No Major.Minor.Patch elements found", - }, - { - name: "latest version check fails", - current: "v1.0.0", - latest: mock.Versioner{Error: errors.New("kaboom")}, - wantError: "error fetching latest version: kaboom", - }, - { - name: "same version", - current: "v1.2.3", - latest: mock.Versioner{Version: "v1.2.3"}, - wantCurrent: semver.MustParse("1.2.3"), - wantLatest: semver.MustParse("1.2.3"), - wantUpdate: false, - }, - { - name: "new version", - current: "v1.2.3", - latest: mock.Versioner{Version: "v1.2.4"}, - wantCurrent: semver.MustParse("1.2.3"), - wantLatest: semver.MustParse("1.2.4"), - wantUpdate: true, - }, - } { - t.Run(testcase.name, func(t *testing.T) { - current, latest, shouldUpdate, err := update.Check(context.Background(), testcase.current, testcase.latest) - if testcase.wantError != "" { - if want, have := testcase.wantError, err; want != have.Error() { - t.Fatalf("error: want %q, have %q", want, have.Error()) - } - return - } - if err != nil { - t.Fatal(err) - } - if want, have := testcase.wantCurrent, current; !want.Equals(have) { - t.Fatalf("current version: want %s, have %s", want, have) - } - if want, have := testcase.wantLatest, latest; !want.Equals(have) { - t.Fatalf("latest versiopn: want %s, have %s", want, have) - } - if want, have := testcase.wantUpdate, shouldUpdate; want != have { - t.Fatalf("should update: want %v, have %v", want, have) - } - }) - } -} - -func TestCheckAsync(t *testing.T) { - for _, testcase := range []struct { - name string - file config.File - currentVersion string - cliVersioner update.Versioner - wantOutput string - }{ - { - name: "no last_check same version", - currentVersion: "0.0.1", - cliVersioner: mock.Versioner{Version: "0.0.1"}, - }, - { - name: "no last_check new version", - file: config.File{ - CLI: config.CLI{ - TTL: "24h", - }, - }, - currentVersion: "0.0.1", - cliVersioner: mock.Versioner{Version: "0.0.2"}, - wantOutput: "\nA new version of the Fastly CLI is available.\nCurrent version: 0.0.1\nLatest version: 0.0.2\nRun `fastly update` to get the latest version.\n\n", - }, - { - name: "recent last_check new version", - file: config.File{ - CLI: config.CLI{ - LastChecked: time.Now().Add(-4 * time.Hour).Format(time.RFC3339), - TTL: "5m", - }, - }, - currentVersion: "0.0.1", - cliVersioner: mock.Versioner{Version: "0.0.2"}, - wantOutput: "\nA new version of the Fastly CLI is available.\nCurrent version: 0.0.1\nLatest version: 0.0.2\nRun `fastly update` to get the latest version.\n\n", - }, - } { - t.Run(testcase.name, func(t *testing.T) { - configFilePath := filepath.Join(os.TempDir(), fmt.Sprintf("fastly_TestCheckAsync_%d", time.Now().UnixNano())) - defer os.RemoveAll(configFilePath) - - ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) - defer cancel() - - f := update.CheckAsync(ctx, testcase.file, configFilePath, testcase.currentVersion, testcase.cliVersioner) - var buf bytes.Buffer - f(&buf) - - if want, have := testcase.wantOutput, buf.String(); want != have { - t.Error(cmp.Diff(want, have)) - } - }) - } -} diff --git a/pkg/update/root.go b/pkg/update/root.go deleted file mode 100644 index 892b78bdb..000000000 --- a/pkg/update/root.go +++ /dev/null @@ -1,93 +0,0 @@ -package update - -import ( - "context" - "fmt" - "io" - "os" - "path/filepath" - - "github.com/fastly/cli/pkg/api" - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/filesystem" - "github.com/fastly/cli/pkg/revision" - "github.com/fastly/cli/pkg/text" -) - -// RootCommand is the parent command for all subcommands in this package. -// It should be installed under the primary root command. -type RootCommand struct { - common.Base - cliVersioner Versioner - client api.HTTPClient - configFilePath string -} - -// NewRootCommand returns a new command registered in the parent. -func NewRootCommand(parent common.Registerer, configFilePath string, cliVersioner Versioner, client api.HTTPClient, globals *config.Data) *RootCommand { - var c RootCommand - c.Globals = globals - c.CmdClause = parent.Command("update", "Update the CLI to the latest version") - c.cliVersioner = cliVersioner - c.client = client - c.configFilePath = configFilePath - return &c -} - -// Exec implements the command interface. -func (c *RootCommand) Exec(in io.Reader, out io.Writer) error { - progress := text.NewQuietProgress(out) - - current, latest, shouldUpdate, err := Check(context.Background(), revision.AppVersion, c.cliVersioner) - if err != nil { - return fmt.Errorf("error checking for latest version: %w", err) - } - - text.Output(out, "Current version: %s", current) - text.Output(out, "Latest version: %s", latest) - if !shouldUpdate { - text.Output(out, "No update required.") - return nil - } - - progress.Step("Fetching latest release...") - latestPath, err := c.cliVersioner.Download(context.Background(), latest) - if err != nil { - progress.Fail() - return fmt.Errorf("error downloading latest release: %w", err) - } - defer os.RemoveAll(latestPath) - - progress.Step("Replacing binary...") - currentPath, err := os.Executable() - if err != nil { - progress.Fail() - return fmt.Errorf("error determining executable path: %w", err) - } - - currentPath, err = filepath.Abs(currentPath) - if err != nil { - progress.Fail() - return fmt.Errorf("error determining absolute target path: %w", err) - } - - if err := os.Rename(latestPath, currentPath); err != nil { - if err := filesystem.CopyFile(latestPath, currentPath); err != nil { - progress.Fail() - return fmt.Errorf("error moving latest binary in place: %w", err) - } - } - - c.Globals.File.CLI.Version = latest.String() - - // Write the file data to disk. - if err := c.Globals.File.Write(c.configFilePath); err != nil { - return fmt.Errorf("error saving config file: %w", err) - } - - progress.Done() - - text.Success(out, "Updated %s to %s.", currentPath, latest) - return nil -} diff --git a/pkg/update/versioner.go b/pkg/update/versioner.go deleted file mode 100644 index 12475bac2..000000000 --- a/pkg/update/versioner.go +++ /dev/null @@ -1,170 +0,0 @@ -package update - -import ( - "context" - "fmt" - "io" - "net/http" - "os" - "path/filepath" - "runtime" - "strings" - "time" - - "github.com/blang/semver" - "github.com/google/go-github/v28/github" - "github.com/mholt/archiver" -) - -// Versioner describes a source of CLI release artifacts. -type Versioner interface { - LatestVersion(context.Context) (semver.Version, error) - Download(context.Context, semver.Version) (filename string, err error) -} - -// GitHub is a versioner that uses GitHub releases. -type GitHub struct { - client *github.Client - org string - repo string - binary string // name of compiled binary - local string // name to use for binary once extracted -} - -// NewGitHub returns a usable GitHub versioner utilizing the provided token. -func NewGitHub(ctx context.Context, org string, repo string, binary string) *GitHub { - var ( - githubClient = github.NewClient(nil) - ) - return &GitHub{ - client: githubClient, - org: org, - repo: repo, - binary: binary, - } -} - -// RenameLocalBinary will rename the downloaded binary. -// -// NOTE: this exists so that we can, for example, rename a binary such as -// 'viceroy' to something less ambiguous like 'fastly-localtesting'. -func (g *GitHub) RenameLocalBinary(s string) error { - g.local = s - return nil -} - -// LatestVersion implements the Versioner interface. -func (g GitHub) LatestVersion(ctx context.Context) (semver.Version, error) { - release, _, err := g.client.Repositories.GetLatestRelease(ctx, g.org, g.repo) - if err != nil { - return semver.Version{}, err - } - return semver.Parse(strings.TrimPrefix(release.GetName(), "v")) -} - -// Download implements the Versioner interface. -func (g GitHub) Download(ctx context.Context, version semver.Version) (filename string, err error) { - releaseID, err := g.getReleaseID(ctx, version) - if err != nil { - return filename, err - } - - release, _, err := g.client.Repositories.GetRelease(ctx, g.org, g.repo, releaseID) - if err != nil { - return filename, fmt.Errorf("error fetching release: %w", err) - } - - assetID, err := g.getAssetID(release.Assets, version) - if err != nil { - return filename, err - } - - rc, redir, err := g.client.Repositories.DownloadReleaseAsset(ctx, g.org, g.repo, assetID) - if err != nil { - return filename, err - } - if redir != "" { - // gosec flagged this: - // G107 (CWE-88): Potential HTTP request made with variable url. - // Disabling as we trust the source of the URL variable. - /* #nosec */ - resp, err := http.Get(redir) - if err != nil { - return filename, err - } - if resp.StatusCode != http.StatusOK { - return filename, fmt.Errorf("GitHub gave %s", resp.Status) - } - rc = resp.Body - } - defer rc.Close() - - archivePath := filepath.Join(os.TempDir(), fmt.Sprintf("%s_%s.tgz", g.binary, version)) - dst, err := os.Create(archivePath) - if err != nil { - return filename, fmt.Errorf("error creating temp file: %w", err) - } - defer os.RemoveAll(archivePath) - - _, err = io.Copy(dst, rc) - if err != nil { - return filename, fmt.Errorf("error downloading release: %w", err) - } - - if err := dst.Close(); err != nil { - return filename, fmt.Errorf("error closing release file: %w", err) - } - - binaryPath := filepath.Join(os.TempDir(), fmt.Sprintf("%s_%s_%d", g.binary, version, time.Now().UnixNano())) - if err := archiver.NewTarGz().Extract(archivePath, g.binary, binaryPath); err != nil { - return filename, fmt.Errorf("error extracting binary: %w", err) - } - - latestPath := filepath.Join(binaryPath, g.binary) - - if g.local != "" { - if err := os.Rename(latestPath, filepath.Join(binaryPath, g.local)); err != nil { - return filename, fmt.Errorf("error renaming binary: %w", err) - } - latestPath = filepath.Join(binaryPath, g.local) - } - - return latestPath, nil -} - -func (g GitHub) getReleaseID(ctx context.Context, version semver.Version) (id int64, err error) { - var ( - page int - versionStr = version.String() - vVersionStr = "v" + versionStr - ) - for { - releases, resp, err := g.client.Repositories.ListReleases(ctx, g.org, g.repo, &github.ListOptions{ - Page: page, - PerPage: 100, - }) - if err != nil { - return id, err - } - for _, release := range releases { - if name := release.GetName(); name == versionStr || name == vVersionStr { - return release.GetID(), nil - } - } - if resp.NextPage == 0 { - break - } - page = resp.NextPage - } - return id, fmt.Errorf("no matching release found") -} - -func (g GitHub) getAssetID(assets []github.ReleaseAsset, version semver.Version) (id int64, err error) { - target := fmt.Sprintf("%s_v%s_%s-%s.tar.gz", g.binary, version, runtime.GOOS, runtime.GOARCH) - for _, asset := range assets { - if asset.GetName() == target { - return asset.GetID(), nil - } - } - return id, fmt.Errorf("no asset found for your OS (%s) and architecture (%s)", runtime.GOOS, runtime.GOARCH) -} diff --git a/pkg/useragent/doc.go b/pkg/useragent/doc.go new file mode 100644 index 000000000..29902faa6 --- /dev/null +++ b/pkg/useragent/doc.go @@ -0,0 +1,2 @@ +// Package useragent contains variables for managing the User-Agent string. +package useragent diff --git a/pkg/version/root.go b/pkg/version/root.go deleted file mode 100644 index 0f213e4da..000000000 --- a/pkg/version/root.go +++ /dev/null @@ -1,47 +0,0 @@ -package version - -import ( - "fmt" - "io" - "strings" - - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/revision" - "github.com/fastly/cli/pkg/useragent" - "github.com/fastly/go-fastly/v3/fastly" -) - -func init() { - // Override the go-fastly UserAgent value by prepending the CLI version. - // - // Results in a header similar too: - // User-Agent: FastlyCLI/0.1.0, FastlyGo/1.5.0 (1.13.0) - fastly.UserAgent = fmt.Sprintf("%s, %s", useragent.Name, fastly.UserAgent) -} - -// RootCommand is the parent command for all subcommands in this package. -// It should be installed under the primary root command. -type RootCommand struct { - common.Base -} - -// NewRootCommand returns a new command registered in the parent. -func NewRootCommand(parent common.Registerer) *RootCommand { - var c RootCommand - c.CmdClause = parent.Command("version", "Display version information for the Fastly CLI") - return &c -} - -// Exec implements the command interface. -func (c *RootCommand) Exec(in io.Reader, out io.Writer) error { - fmt.Fprintf(out, "Fastly CLI version %s (%s)\n", revision.AppVersion, revision.GitCommit) - fmt.Fprintf(out, "Built with %s\n", revision.GoVersion) - return nil -} - -// IsPreRelease determines if the given app version is a pre-release. -// -// NOTE: this is indicated by the presence of a hyphen, e.g. v1.0.0-rc.1 -func IsPreRelease(version string) bool { - return strings.Contains(version, "-") -} diff --git a/pkg/version/version_test.go b/pkg/version/version_test.go deleted file mode 100644 index e725935e9..000000000 --- a/pkg/version/version_test.go +++ /dev/null @@ -1,36 +0,0 @@ -package version_test - -import ( - "bytes" - "io" - "strings" - "testing" - - "github.com/fastly/cli/pkg/api" - "github.com/fastly/cli/pkg/app" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/mock" - "github.com/fastly/cli/pkg/testutil" - "github.com/fastly/cli/pkg/update" -) - -func TestVersion(t *testing.T) { - var ( - args = []string{"version"} - env = config.Environment{} - file = config.File{} - configFileName = "/dev/null" - clientFactory = mock.APIClient(mock.API{}) - httpClient api.HTTPClient = nil - cliVersioner update.Versioner = mock.Versioner{Version: "v1.2.3"} - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, configFileName, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertNoError(t, err) - testutil.AssertString(t, strings.Join([]string{ - "Fastly CLI version v0.0.0-unknown (unknown)", - "Built with go version unknown", - "", - }, "\n"), out.String()) -} diff --git a/pkg/whoami/root.go b/pkg/whoami/root.go deleted file mode 100644 index 68640655f..000000000 --- a/pkg/whoami/root.go +++ /dev/null @@ -1,125 +0,0 @@ -package whoami - -import ( - "encoding/json" - "fmt" - "io" - "net/http" - "sort" - "strings" - - "github.com/fastly/cli/pkg/api" - "github.com/fastly/cli/pkg/common" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/errors" - "github.com/fastly/cli/pkg/useragent" -) - -// RootCommand is the parent command for all subcommands in this package. -// It should be installed under the primary root command. -type RootCommand struct { - common.Base - client api.HTTPClient -} - -// NewRootCommand returns a new command registered in the parent. -func NewRootCommand(parent common.Registerer, client api.HTTPClient, globals *config.Data) *RootCommand { - var c RootCommand - c.Globals = globals - c.client = client - c.CmdClause = parent.Command("whoami", "Get information about the currently authenticated account") - return &c -} - -// Exec implements the command interface. -func (c *RootCommand) Exec(in io.Reader, out io.Writer) error { - endpoint, _ := c.Globals.Endpoint() - fullurl := fmt.Sprintf("%s/verify", strings.TrimSuffix(endpoint, "/")) - req, err := http.NewRequest("GET", fullurl, nil) - if err != nil { - return fmt.Errorf("error constructing API request: %w", err) - } - - token, source := c.Globals.Token() - if source == config.SourceUndefined { - return errors.ErrNoToken - } - - req.Header.Set("Fastly-Key", token) - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", useragent.Name) - resp, err := c.client.Do(req) - if err != nil { - return fmt.Errorf("error executing API request: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("error from API: %s", resp.Status) - } - - var response VerifyResponse - if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { - return fmt.Errorf("error decoding API response: %w", err) - } - - if !c.Globals.Verbose() { - fmt.Fprintf(out, "%s <%s>\n", response.User.Name, response.User.Login) - return nil - } - - keys := make([]string, 0, len(response.Services)) - for k := range response.Services { - keys = append(keys, k) - } - sort.Strings(keys) - - fmt.Fprintf(out, "Customer ID: %s\n", response.Customer.ID) - fmt.Fprintf(out, "Customer name: %s\n", response.Customer.Name) - fmt.Fprintf(out, "User ID: %s\n", response.User.ID) - fmt.Fprintf(out, "User name: %s\n", response.User.Name) - fmt.Fprintf(out, "User login: %s\n", response.User.Login) - fmt.Fprintf(out, "Token ID: %s\n", response.Token.ID) - fmt.Fprintf(out, "Token name: %s\n", response.Token.Name) - fmt.Fprintf(out, "Token created at: %s\n", response.Token.CreatedAt) - if response.Token.ExpiresAt != "" { - fmt.Fprintf(out, "Token expires at: %s\n", response.Token.ExpiresAt) - } - fmt.Fprintf(out, "Token scope: %s\n", response.Token.Scope) - fmt.Fprintf(out, "Service count: %d\n", len(response.Services)) - for _, k := range keys { - fmt.Fprintf(out, "\t%s (%s)\n", response.Services[k], k) - } - - return nil -} - -// VerifyResponse models the Fastly API response for the whoami command. -type VerifyResponse struct { - Customer Customer `json:"customer"` - User User `json:"user"` - Services map[string]string `json:"services"` - Token Token `json:"token"` -} - -// Customer is part of the Fastly API repsonse for the whoami command. -type Customer struct { - ID string `json:"id"` - Name string `json:"name"` -} - -// User is part of the Fastly API repsonse for the whoami command. -type User struct { - ID string `json:"id"` - Name string `json:"name"` - Login string `json:"login"` -} - -// Token is part of the Fastly API repsonse for the whoami command. -type Token struct { - ID string `json:"id"` - Name string `json:"name"` - CreatedAt string `json:"created_at"` - ExpiresAt string `json:"expires_at"` - Scope string `json:"scope"` -} diff --git a/pkg/whoami/whoami_test.go b/pkg/whoami/whoami_test.go deleted file mode 100644 index 3a24be489..000000000 --- a/pkg/whoami/whoami_test.go +++ /dev/null @@ -1,168 +0,0 @@ -package whoami_test - -import ( - "bytes" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/fastly/cli/pkg/api" - "github.com/fastly/cli/pkg/app" - "github.com/fastly/cli/pkg/config" - "github.com/fastly/cli/pkg/mock" - "github.com/fastly/cli/pkg/testutil" - "github.com/fastly/cli/pkg/update" - "github.com/fastly/cli/pkg/whoami" -) - -func TestWhoami(t *testing.T) { - for _, testcase := range []struct { - name string - args []string - env config.Environment - file config.File - client api.HTTPClient - wantError string - wantOutput string - }{ - { - name: "no token", - args: []string{"whoami"}, - client: verifyClient(basicResponse), - wantError: "no token provided", - }, - { - name: "basic response", - args: []string{"--token=x", "whoami"}, - client: verifyClient(basicResponse), - wantOutput: basicOutput, - }, - { - name: "basic response verbose", - args: []string{"--token=x", "whoami", "-v"}, - client: verifyClient(basicResponse), - wantOutput: basicOutputVerbose, - }, - { - name: "500 from API", - args: []string{"--token=x", "whoami"}, - client: codeClient{code: http.StatusInternalServerError}, - wantError: "error from API: 500 Internal Server Error", - }, - { - name: "local error", - args: []string{"--token=x", "whoami"}, - client: errorClient{err: errors.New("some network failure")}, - wantError: "error executing API request: some network failure", - }, - { - name: "alternative endpoint from flag", - args: []string{"--token=x", "whoami", "--endpoint=https://staging.fastly.com", "-v"}, - client: verifyClient(basicResponse), - wantOutput: strings.ReplaceAll(basicOutputVerbose, - "Fastly API endpoint: https://api.fastly.com", - "Fastly API endpoint: https://staging.fastly.com", - ), - }, - { - name: "alternative endpoint from environment", - args: []string{"--token=x", "whoami", "-v"}, - env: config.Environment{Endpoint: "https://alternative.example.com"}, - client: verifyClient(basicResponse), - wantOutput: strings.ReplaceAll(basicOutputVerbose, - "Fastly API endpoint: https://api.fastly.com", - fmt.Sprintf("Fastly API endpoint (via %s): https://alternative.example.com", config.EnvVarEndpoint), - ), - }, - } { - t.Run(testcase.name, func(t *testing.T) { - var ( - args = testcase.args - env = testcase.env - file = testcase.file - configFileName = "/dev/null" - clientFactory = mock.APIClient(mock.API{}) - httpClient = testcase.client - cliVersioner update.Versioner = nil - in io.Reader = nil - out bytes.Buffer - ) - err := app.Run(args, env, file, configFileName, clientFactory, httpClient, cliVersioner, in, &out) - testutil.AssertErrorContains(t, err, testcase.wantError) - testutil.AssertStringContains(t, out.String(), testcase.wantOutput) - }) - } -} - -type verifyClient whoami.VerifyResponse - -func (c verifyClient) Do(*http.Request) (*http.Response, error) { - rec := httptest.NewRecorder() - json.NewEncoder(rec).Encode(whoami.VerifyResponse(c)) - return rec.Result(), nil -} - -type codeClient struct { - code int -} - -func (c codeClient) Do(*http.Request) (*http.Response, error) { - rec := httptest.NewRecorder() - rec.WriteHeader(c.code) - return rec.Result(), nil -} - -type errorClient struct { - err error -} - -func (c errorClient) Do(*http.Request) (*http.Response, error) { - return nil, c.err -} - -var basicResponse = whoami.VerifyResponse{ - Customer: whoami.Customer{ - ID: "abc", - Name: "Computer Company", - }, - User: whoami.User{ - ID: "123", - Name: "Alice Programmer", - Login: "alice@example.com", - }, - Services: map[string]string{ - "1xxaa": "First service", - "2baba": "Second service", - }, - Token: whoami.Token{ - ID: "abcdefg", - Name: "Token name", - CreatedAt: "2019-01-01T12:00:00Z", - // no ExpiresAt - Scope: "global", - }, -} - -var basicOutput = "Alice Programmer \n" - -var basicOutputVerbose = strings.TrimSpace(` -Fastly API token provided via --token -Fastly API endpoint: https://api.fastly.com -Customer ID: abc -Customer name: Computer Company -User ID: 123 -User name: Alice Programmer -User login: alice@example.com -Token ID: abcdefg -Token name: Token name -Token created at: 2019-01-01T12:00:00Z -Token scope: global -Service count: 2 - First service (1xxaa) - Second service (2baba) -`) + "\n" diff --git a/scripts/changelog.sh b/scripts/changelog.sh deleted file mode 100755 index a0035eb53..000000000 --- a/scripts/changelog.sh +++ /dev/null @@ -1,26 +0,0 @@ -#!/bin/bash -set -e - -if ! command -v github_changelog_generator > /dev/null; then - echo "No github_changelog_generator in \$PATH, install via 'gem install github_changelog_generator'." - exit 1 -fi - -if [ -z "$CHANGELOG_GITHUB_TOKEN" ]; then - printf "\nWARNING: No \$CHANGELOG_GITHUB_TOKEN in environment, set one to avoid hitting rate limit.\n\n" -fi - -if [ -z "$SEMVER_TAG" ]; then - echo "You must set \$SEMVER_TAG to your desired release semver version." - exit 1 -fi - -github_changelog_generator -u fastly -p cli \ - --future-release $SEMVER_TAG \ - --no-pr-wo-labels \ - --no-author \ - --enhancement-label "**Enhancements:**" \ - --bugs-label "**Bug fixes:**" \ - --release-url "https://github.com/fastly/cli/releases/tag/%s" \ - --exclude-labels documentation \ - --exclude-tags-regex "v.*-.*" diff --git a/scripts/config.sh b/scripts/config.sh new file mode 100755 index 000000000..2249be686 --- /dev/null +++ b/scripts/config.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash + +set -e + +cp ".fastly/config.toml" "pkg/config/config.toml" + +if ! command -v tq &> /dev/null +then + cargo install tomlq +fi + +kits=( + compute-starter-kit-go-default + compute-starter-kit-go-tinygo + compute-starter-kit-javascript-default + compute-starter-kit-javascript-empty + compute-starter-kit-rust-default + compute-starter-kit-rust-empty + compute-starter-kit-rust-static-content + compute-starter-kit-rust-websockets + compute-starter-kit-typescript +) + +function parse() { + tq -r -f "$k.toml" $1 +} + +function append() { + echo $1 >>pkg/config/config.toml +} + +for k in ${kits[@]}; do + curl -s "https://raw.githubusercontent.com/fastly/$k/main/fastly.toml" -o "$k.toml" + + append '' + append "[[starter-kits.$(parse language)]]" + append "description = \"$(parse description)\"" + append "name = \"$(parse name)\"" + append "path = \"https://github.com/fastly/$k\"" + + rm "$k.toml" +done diff --git a/scripts/documentation.sh b/scripts/documentation.sh index 229d44ee3..c944f39ea 100755 --- a/scripts/documentation.sh +++ b/scripts/documentation.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash set -e $1 help --format json > dist/usage.json diff --git a/scripts/go-test-cache/go.mod b/scripts/go-test-cache/go.mod new file mode 100644 index 000000000..c0cda9a33 --- /dev/null +++ b/scripts/go-test-cache/go.mod @@ -0,0 +1,3 @@ +module go-test-cache + +go 1.20 diff --git a/scripts/go-test-cache/main.go b/scripts/go-test-cache/main.go new file mode 100644 index 000000000..70f730df4 --- /dev/null +++ b/scripts/go-test-cache/main.go @@ -0,0 +1,119 @@ +// This code is based on the following script and was generated using AI. +// https://github.com/airplanedev/blog-examples/blob/main/go-test-caching/update_file_timestamps.py?ref=airplane.ghost.io +// +// REFERENCE ARTICLE: +// https://www.airplane.dev/blog/caching-golang-tests-in-ci#:~:text=fixed%20that%20problem.-,Reading%20fixtures,-A%20third%20issue +package main + +import ( + "crypto/sha1" + "io" + "log" + "os" + "path/filepath" + "sort" + "strings" + "time" +) + +const ( + bufSize = 65536 + baseDate = 1684178360 + timeFormat = "2006-01-02 15:04:05" +) + +func main() { + repoRoot := "." + allDirs := make([]string, 0) + + err := filepath.Walk(repoRoot, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + if info.IsDir() { + dirPath := filepath.Join(repoRoot, path) + relPath, _ := filepath.Rel(repoRoot, dirPath) + + if strings.HasPrefix(relPath, ".") { + return nil + } + + allDirs = append(allDirs, dirPath) + } else { + filePath := filepath.Join(repoRoot, path) + relPath, _ := filepath.Rel(repoRoot, filePath) + + if strings.HasPrefix(relPath, ".") { + return nil + } + + sha1Hash, err := getFileSHA1(filePath) + if err != nil { + return err + } + + modTime := getModifiedTime(sha1Hash) + + log.Printf("Setting modified time of file %s to %s\n", relPath, modTime.Format(timeFormat)) + err = os.Chtimes(filePath, modTime, modTime) + if err != nil { + return err + } + } + + return nil + }) + if err != nil { + log.Fatal("Error:", err) + } + + sort.Slice(allDirs, func(i, j int) bool { + return len(allDirs[i]) > len(allDirs[j]) || (len(allDirs[i]) == len(allDirs[j]) && allDirs[i] < allDirs[j]) + }) + + for _, dirPath := range allDirs { + relPath, _ := filepath.Rel(repoRoot, dirPath) + + log.Printf("Setting modified time of directory %s to %s\n", relPath, time.Unix(baseDate, 0).Format(timeFormat)) + err := os.Chtimes(dirPath, time.Unix(baseDate, 0), time.Unix(baseDate, 0)) + if err != nil { + log.Fatal("Error:", err) + } + } + + log.Println("Done") +} + +func getFileSHA1(filePath string) (string, error) { + file, err := os.Open(filePath) + if err != nil { + return "", err + } + defer file.Close() + + // G401: Use of weak cryptographic primitive + // Disabling as the hash is used not for security reasons. + // The hash is used as a cache key to improve test run times. + // #nosec + // nosemgrep: go.lang.security.audit.crypto.use_of_weak_crypto.use-of-sha1 + hash := sha1.New() + if _, err := io.CopyBuffer(hash, file, make([]byte, bufSize)); err != nil { + return "", err + } + + return string(hash.Sum(nil)), nil +} + +func getModifiedTime(sha1Hash string) time.Time { + hashBytes := []byte(sha1Hash) + lastFiveBytes := hashBytes[:5] + lastFiveValue := int64(0) + + for _, b := range lastFiveBytes { + lastFiveValue = (lastFiveValue << 8) + int64(b) + } + + modTime := baseDate - (lastFiveValue % 10000) + return time.Unix(modTime, 0) +} diff --git a/scripts/release-changelog.sh b/scripts/release-changelog.sh deleted file mode 100755 index 796ead9e7..000000000 --- a/scripts/release-changelog.sh +++ /dev/null @@ -1,24 +0,0 @@ -#!/bin/bash -set -e - -prev_tag="$(source scripts/tags.sh; previous_tag)" - -if ! command -v github_changelog_generator > /dev/null; then - echo "No github_changelog_generator in \$PATH, install via 'gem install github_changelog_generator'." - exit 1 -fi - -if [ -z "$CHANGELOG_GITHUB_TOKEN" ]; then - printf "\nWARNING: No \$CHANGELOG_GITHUB_TOKEN in environment, set one to avoid hitting rate limit.\n\n" -fi - -github_changelog_generator -u fastly -p cli \ - --no-pr-wo-labels \ - --no-author \ - --no-issues \ - --enhancement-label "**Enhancements:**" \ - --bugs-label "**Bug fixes:**" \ - --release-url "https://github.com/fastly/cli/releases/tag/%s" \ - --exclude-labels documentation \ - --output RELEASE_CHANGELOG.md \ - --since-tag $prev_tag diff --git a/scripts/scaffold-category.sh b/scripts/scaffold-category.sh new file mode 100755 index 000000000..5fc737e9a --- /dev/null +++ b/scripts/scaffold-category.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +set -e + +export CLI_CATEGORY=$1 +export CLI_CATEGORY_COMMAND=$2 +export CLI_PACKAGE=$3 +export CLI_COMMAND=$4 +export CLI_API=$5 + +mkdir -p pkg/commands/$CLI_CATEGORY/$CLI_PACKAGE + +# CREATE NEW CATEGORY FILES +# +# NOTE: We avoid recreating the files if they already exist, which can happen +# if adding a new command to an existing category (e.g. adding a new logging +# endpoint to the logging category). +# +if [ ! -f "pkg/commands/$CLI_CATEGORY/doc.go" ]; then + cat .tmpl/doc_parent.go | envsubst > pkg/commands/$CLI_CATEGORY/doc.go +fi +if [ ! -f "pkg/commands/$CLI_CATEGORY/root.go" ]; then + cat .tmpl/root_parent.go | envsubst > pkg/commands/$CLI_CATEGORY/root.go +fi + +# CREATE NEW COMMAND FILES +# +cat .tmpl/test.go | envsubst > pkg/commands/$CLI_CATEGORY/$CLI_PACKAGE/${CLI_PACKAGE}_test.go +filenames=("create" "delete" "describe" "doc" "list" "root" "update") +for filename in "${filenames[@]}"; do + cat .tmpl/$filename.go | envsubst > pkg/commands/$CLI_CATEGORY/$CLI_PACKAGE/$filename.go +done + +source ./scripts/scaffold-update-interfaces.sh diff --git a/scripts/scaffold-update-interfaces.sh b/scripts/scaffold-update-interfaces.sh new file mode 100755 index 000000000..32602b70e --- /dev/null +++ b/scripts/scaffold-update-interfaces.sh @@ -0,0 +1,159 @@ +#!/usr/bin/env bash +set -e + +# UPDATE INTERFACE FILE +# +# The interface file contains all the API functions we expect to use from the +# go-fastly SDK. When adding a new command, we want to update this file to +# reflect any new API functions we're intending to use. +# +# The logic in this file is more complex than the other scaffolding scripts +# because we're manipulating an existing file that isn't code-generated. +# +# I use Vim to handle the processing because it's easier for me (@integralist) +# to write the otherwise complex logic, compared to trying to use bash or some +# other tool such as Awk. +# +# STEPS: +# - We locate the Interface type. +# - Copy the last set of interface methods. +# - Capture line number for start of copied methods (to use in substitution). +# - Rename the API (three separate places per line). +# +# NOTE: +# Any backslash in the substitution commands (e.g. \v) need to be double escaped. +# - Once because the backslash is inside the :exe command's expected string. +# - And then again because of the parent HEREDOC container. +# +# CAVEATS: +# This isn't a perfect process. Its successfulness is based on whether the last +# set of commands align with our expectations. It will still produce ~95% +# expected output, but if there's an extra API function (e.g. BatchModify) then +# that line won't have the relevant API name replaced as we only look for the +# common CRUD methods (Create, Delete, Get, List, Update). +# +vim -E -s pkg/api/interface.go <<-EOF + :g/type Interface interface/norm $%k + :norm V{yP]mk + :norm { + :call setreg('a', line('.')) + :norm ]mk + :exe getreg("a")","line(".")"s/\\\v(Create|Delete|Get|List|Update)[^(]+/\\\1${CLI_API}/" + :exe getreg("a")","line(".")"s/\\\v(fastly\\\.)(Create|Delete|Get|List|Update)[^)]+(Input)/\\\1\\\2${CLI_API}\\\3/" + :exe getreg("a")","line(".")"s/\\\v\\\((\\\[\\\])?\\\*(fastly\\\.)[^,]+/(\\\1*\\\2${CLI_API}/" + :exe getreg("a")","line(".")"s/\\\v(List${CLI_API})/\\\1s/g" + :update + :quit +EOF + +# The following is essentially the same as above, but we tweak the first :exe +# substitution a bit to fit the format of the mock interface file. +# +vim -E -s pkg/mock/api.go <<-EOF + :g/type API struct/norm $%k + :norm V{yP]mk + :norm { + :call setreg('a', line('.')) + :norm ]mk + :exe getreg("a")","line(".")"s/\\\v(Create|Delete|Get|List|Update)[^(]+/\\\1${CLI_API}Fn func/" + :exe getreg("a")","line(".")"s/\\\v(fastly\\\.)(Create|Delete|Get|List|Update)[^)]+(Input)/\\\1\\\2${CLI_API}\\\3/" + :exe getreg("a")","line(".")"s/\\\v\\\((\\\[\\\])?\\\*(fastly\\\.)[^,]+/(\\\1*\\\2${CLI_API}/" + :exe getreg("a")","line(".")"s/\\\v(List${CLI_API})/\\\1s/g" + :update + :quit +EOF + +# Additionally, we have to create mock implementations of the CRUD functions, +# so we have to copy an existing function and then do similar substitutions. +# +functions=("Create" "Delete" "Get" "List" "Update") +for fn in "${functions[@]}"; do + vim -E -s pkg/mock/api.go <<-EOF + :$ + :norm V{yPG + :norm { + :call setreg('a', line('.')) + :$ + :exe getreg("a")","line(".")"s/\\\v(return m\\\.)(Create|Delete|Get|List|Update)[^(]+/\\\1${fn}${CLI_API}Fn/" + :exe getreg("a")","line(".")"s/\\\v\\\) (Create|Delete|Get|List|Update)[^(]+/) ${fn}${CLI_API}/" + :exe getreg("a")","line(".")"s/\\\v(fastly\\\.)(Create|Delete|Get|List|Update)[^)]+(Input)/\\\1${fn}${CLI_API}\\\3/" + :exe getreg("a")","line(".")"s/\\\v\\\((\\\*fastly\\\.)[^,]+/(\\\1${CLI_API}/" + :exe getreg("a")","line(".")"s/\\\v^(\\\/\\\/) (Create|Delete|Get|List|Update)(\\\w+)( implements)/\\\1 ${fn}${CLI_API}\\\4/" + :update + :quit + EOF + + # List needs a plural for its name. + # We can't combine this substitution with the above because of the potential + # ordering of commands generated (i.e. it could cause another method to be + # incorrectly updated). + vim -E -s pkg/mock/api.go <<-EOF + :$ + :norm {{ + :,+4s/\\v(List${CLI_API})/\\1s/ge + :update + :quit + EOF +done + + +# UPDATE RUN FILE +# +# The run file contains all the CLI commands we expect to expose to users. +# We want to update this file to reflect any new commands we've added. +# +# STEPS: +# - We locate an existing command we want to copy. +# - Copy the command instantiations. +# - Rename the package name. +# - Yank the new commands to the vim register. +# - Insert the new commands into the list that will be parsed by cmd.Select() +# +# The command we copy depends on whether we're creating a top-level command or +# a category command. If the former we copy the 'backend' command set, if the +# latter we'll copy the 'vcl' command set as it defines the category as a root +# command and passes that to the nested root command. +# +# NOTE: +# Any backslash in the substitution commands need to be escaped because of the +# parent HEREDOC container. +# +# Although it looks like the list of commands in run.go is sorted, they are +# actually manually ordered alphabetically and that's because each commands +# 'root' command needs to be at the top, and sorting the list would cause that +# to break. So it's important you don't attempt to sort the list. The purpose of +# this automation script is to save some manual key strokes. You'll have to +# manually sort the newly created lines yourself. +# +if [[ -z "${CLI_CATEGORY}" ]]; then +vim -E -s pkg/app/commands.go <<-EOF + :g/backendCmdRoot :=/norm 0 + :norm V5jyP + :,+5s/backend/${CLI_PACKAGE}/g + :norm V5k"ay + :g/return \\[]cmd.Command/norm 0 + :norm "ap + :,+5s/\\v :=.+/,/ + :norm V5k> + :update + :quit +EOF +else +vim -E -s pkg/app/commands.go <<-EOF + :g/vclCmdRoot :=/norm 0 + :norm V6jyP + :,+6s/vcl/${CLI_CATEGORY}/g + :-6 + :,+6s/custom\\./${CLI_PACKAGE}./g + :-6 + :,+6s/Custom/\\u${CLI_PACKAGE}/g + :-6 + :norm V6j"ay + :g/return \\[]cmd.Command/norm 0 + :norm "ap + :,+6s/\\v :=.+/,/ + :norm V6k> + :update + :quit +EOF +fi diff --git a/scripts/scaffold.sh b/scripts/scaffold.sh new file mode 100755 index 000000000..602f46297 --- /dev/null +++ b/scripts/scaffold.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -e + +export CLI_PACKAGE=$1 +export CLI_COMMAND=$2 +export CLI_API=$3 + +mkdir -p pkg/commands/$CLI_PACKAGE + +# CREATE NEW COMMAND FILES +# +cat .tmpl/test.go | envsubst > pkg/commands/$CLI_PACKAGE/${CLI_PACKAGE}_test.go +filenames=("create" "delete" "describe" "doc" "list" "root" "update") +for filename in "${filenames[@]}"; do + cat .tmpl/$filename.go | envsubst > pkg/commands/$CLI_PACKAGE/$filename.go +done + +source ./scripts/scaffold-update-interfaces.sh diff --git a/scripts/tags.sh b/scripts/tags.sh index 8b1cf0031..03778f391 100755 --- a/scripts/tags.sh +++ b/scripts/tags.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash set -e # credit: https://github.com/cli/cli/blob/trunk/script/changelog diff --git a/tools.mod b/tools.mod new file mode 100644 index 000000000..e5b2a5647 --- /dev/null +++ b/tools.mod @@ -0,0 +1,335 @@ +module github.com/fastly/cli/tools + +go 1.24.0 + +toolchain go1.24.3 + +tool ( + github.com/goreleaser/goreleaser/v2 + github.com/ofabry/go-callvis +) + +require ( + cel.dev/expr v0.22.1 // indirect + cloud.google.com/go v0.120.0 // indirect + cloud.google.com/go/auth v0.15.0 // indirect + cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect + cloud.google.com/go/compute/metadata v0.6.0 // indirect + cloud.google.com/go/iam v1.4.2 // indirect + cloud.google.com/go/kms v1.21.1 // indirect + cloud.google.com/go/longrunning v0.6.6 // indirect + cloud.google.com/go/monitoring v1.24.1 // indirect + cloud.google.com/go/storage v1.51.0 // indirect + code.gitea.io/sdk/gitea v0.21.0 // indirect + dario.cat/mergo v1.0.1 // indirect + github.com/42wim/httpsig v1.2.2 // indirect + github.com/AlekSi/pointer v1.2.0 // indirect + github.com/Azure/azure-sdk-for-go v68.0.0+incompatible // indirect + github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.1 // indirect + github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.8.2 // indirect + github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 // indirect + github.com/Azure/azure-sdk-for-go/sdk/keyvault/azkeys v0.10.0 // indirect + github.com/Azure/azure-sdk-for-go/sdk/keyvault/internal v0.7.1 // indirect + github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.0 // indirect + github.com/Azure/go-autorest v14.2.0+incompatible // indirect + github.com/Azure/go-autorest/autorest v0.11.29 // indirect + github.com/Azure/go-autorest/autorest/adal v0.9.23 // indirect + github.com/Azure/go-autorest/autorest/azure/auth v0.5.12 // indirect + github.com/Azure/go-autorest/autorest/azure/cli v0.4.6 // indirect + github.com/Azure/go-autorest/autorest/date v0.3.0 // indirect + github.com/Azure/go-autorest/autorest/to v0.4.1 // indirect + github.com/Azure/go-autorest/logger v0.2.1 // indirect + github.com/Azure/go-autorest/tracing v0.6.0 // indirect + github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 // indirect + github.com/BurntSushi/toml v1.5.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.27.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0 // indirect + github.com/Masterminds/goutils v1.1.1 // indirect + github.com/Masterminds/semver/v3 v3.3.1 // indirect + github.com/Masterminds/sprig/v3 v3.3.0 // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/ProtonMail/go-crypto v1.1.6 // indirect + github.com/agnivade/levenshtein v1.2.1 // indirect + github.com/alessio/shellescape v1.4.2 // indirect + github.com/anchore/bubbly v0.0.0-20241107060245-f2a5536f366a // indirect + github.com/anchore/go-logger v0.0.0-20241005132348-65b4486fbb28 // indirect + github.com/anchore/go-macholibre v0.0.0-20220308212642-53e6d0aaf6fb // indirect + github.com/anchore/quill v0.5.1 // indirect + github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect + github.com/atc0005/go-teams-notify/v2 v2.13.0 // indirect + github.com/aws/aws-sdk-go v1.55.6 // indirect + github.com/aws/aws-sdk-go-v2 v1.36.3 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10 // indirect + github.com/aws/aws-sdk-go-v2/config v1.29.12 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.17.65 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30 // indirect + github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.69 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.34 // indirect + github.com/aws/aws-sdk-go-v2/service/ecr v1.40.3 // indirect + github.com/aws/aws-sdk-go-v2/service/ecrpublic v1.31.2 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.0 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.15 // indirect + github.com/aws/aws-sdk-go-v2/service/kms v1.38.1 // indirect + github.com/aws/aws-sdk-go-v2/service/s3 v1.78.2 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.25.2 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.0 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.33.17 // indirect + github.com/aws/smithy-go v1.22.3 // indirect + github.com/awslabs/amazon-ecr-credential-helper/ecr-login v0.9.1 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/blacktop/go-dwarf v1.0.10 // indirect + github.com/blacktop/go-macho v1.1.238 // indirect + github.com/blakesmith/ar v0.0.0-20190502131153-809d4375e1fb // indirect + github.com/blang/semver v3.5.1+incompatible // indirect + github.com/bluesky-social/indigo v0.0.0-20240813042137-4006c0eca043 // indirect + github.com/buger/jsonparser v1.1.1 // indirect + github.com/caarlos0/ctrlc v1.2.0 // indirect + github.com/caarlos0/env/v11 v11.3.1 // indirect + github.com/caarlos0/go-reddit/v3 v3.0.1 // indirect + github.com/caarlos0/go-shellwords v1.0.12 // indirect + github.com/caarlos0/go-version v0.2.0 // indirect + github.com/caarlos0/log v0.4.8 // indirect + github.com/carlmjohnson/versioninfo v0.22.5 // indirect + github.com/cavaliergopher/cpio v1.0.1 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/charmbracelet/bubbletea v1.3.0 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/lipgloss v1.1.0 // indirect + github.com/charmbracelet/x/ansi v0.8.0 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/chrismellard/docker-credential-acr-env v0.0.0-20230304212654-82a0ddb27589 // indirect + github.com/cloudflare/circl v1.3.8 // indirect + github.com/cncf/xds/go v0.0.0-20250326154945-ae57f3c0d45f // indirect + github.com/containerd/stargz-snapshotter/estargz v0.16.3 // indirect + github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect + github.com/cyberphone/json-canonicalization v0.0.0-20231011164504-785e29786b46 // indirect + github.com/cyphar/filepath-securejoin v0.3.6 // indirect + github.com/davidmz/go-pageant v1.0.2 // indirect + github.com/dghubble/go-twitter v0.0.0-20211115160449-93a8679adecb // indirect + github.com/dghubble/oauth1 v0.7.3 // indirect + github.com/dghubble/sling v1.4.0 // indirect + github.com/dimchansky/utfbom v1.1.1 // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/cli v27.5.0+incompatible // indirect + github.com/docker/distribution v2.8.3+incompatible // indirect + github.com/docker/docker v27.5.0+incompatible // indirect + github.com/docker/docker-credential-helpers v0.8.2 // indirect + github.com/docker/go-connections v0.5.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/elliotchance/orderedmap/v2 v2.7.0 // indirect + github.com/emirpasic/gods v1.18.1 // indirect + github.com/envoyproxy/go-control-plane/envoy v1.32.4 // indirect + github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/evanphx/json-patch/v5 v5.6.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/fogleman/gg v1.3.0 // indirect + github.com/fsnotify/fsnotify v1.8.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.8 // indirect + github.com/github/smimesign v0.2.0 // indirect + github.com/go-chi/chi v4.1.2+incompatible // indirect + github.com/go-fed/httpsig v1.1.0 // indirect + github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect + github.com/go-git/go-billy/v5 v5.6.1 // indirect + github.com/go-git/go-git/v5 v5.13.1 // indirect + github.com/go-jose/go-jose/v4 v4.0.5 // indirect + github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-openapi/analysis v0.23.0 // indirect + github.com/go-openapi/errors v0.22.0 // indirect + github.com/go-openapi/jsonpointer v0.21.0 // indirect + github.com/go-openapi/jsonreference v0.21.0 // indirect + github.com/go-openapi/loads v0.22.0 // indirect + github.com/go-openapi/runtime v0.28.0 // indirect + github.com/go-openapi/spec v0.21.0 // indirect + github.com/go-openapi/strfmt v0.23.0 // indirect + github.com/go-openapi/swag v0.23.0 // indirect + github.com/go-openapi/validate v0.24.0 // indirect + github.com/go-restruct/restruct v1.2.0-alpha // indirect + github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 // indirect + github.com/gobwas/glob v0.2.3 // indirect + github.com/goccy/go-graphviz v0.1.2 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang-jwt/jwt/v4 v4.5.2 // indirect + github.com/golang-jwt/jwt/v5 v5.2.2 // indirect + github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect + github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect + github.com/google/go-containerregistry v0.20.3 // indirect + github.com/google/go-github/v71 v71.0.0 // indirect + github.com/google/go-querystring v1.1.0 // indirect + github.com/google/ko v0.17.1 // indirect + github.com/google/rpmpack v0.6.1-0.20240329070804-c2247cbb881a // indirect + github.com/google/s2a-go v0.1.9 // indirect + github.com/google/safetext v0.0.0-20220905092116-b49f7bc46da2 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/google/wire v0.6.0 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect + github.com/googleapis/gax-go/v2 v2.14.1 // indirect + github.com/goreleaser/chglog v0.6.2 // indirect + github.com/goreleaser/fileglob v1.3.0 // indirect + github.com/goreleaser/goreleaser/v2 v2.9.0 // indirect + github.com/goreleaser/nfpm/v2 v2.41.3 // indirect + github.com/gorilla/websocket v1.5.1 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/hashicorp/go-retryablehttp v0.7.7 // indirect + github.com/hashicorp/go-version v1.7.0 // indirect + github.com/hashicorp/golang-lru v1.0.2 // indirect + github.com/hashicorp/hcl v1.0.1-vault-5 // indirect + github.com/huandu/xstrings v1.5.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/invopop/jsonschema v0.13.0 // indirect + github.com/ipfs/bbloom v0.0.4 // indirect + github.com/ipfs/go-block-format v0.2.0 // indirect + github.com/ipfs/go-cid v0.4.1 // indirect + github.com/ipfs/go-datastore v0.6.0 // indirect + github.com/ipfs/go-ipfs-blockstore v1.3.1 // indirect + github.com/ipfs/go-ipfs-ds-help v1.1.1 // indirect + github.com/ipfs/go-ipfs-util v0.0.3 // indirect + github.com/ipfs/go-ipld-cbor v0.1.0 // indirect + github.com/ipfs/go-ipld-format v0.6.0 // indirect + github.com/ipfs/go-log v1.0.5 // indirect + github.com/ipfs/go-log/v2 v2.5.1 // indirect + github.com/ipfs/go-metrics-interface v0.0.1 // indirect + github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect + github.com/jbenet/goprocess v0.1.4 // indirect + github.com/jedisct1/go-minisign v0.0.0-20230811132847-661be99b8267 // indirect + github.com/jmespath/go-jmespath v0.4.0 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/kevinburke/ssh_config v1.2.0 // indirect + github.com/klauspost/compress v1.18.0 // indirect + github.com/klauspost/cpuid/v2 v2.2.7 // indirect + github.com/klauspost/pgzip v1.2.6 // indirect + github.com/kylelemons/godebug v1.1.0 // indirect + github.com/letsencrypt/boulder v0.0.0-20240620165639-de9c06129bec // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/magiconair/properties v1.8.7 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.2-0.20220822084749-2491eb6c1c75 // indirect + github.com/mattn/go-mastodon v0.0.9 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/minio/sha256-simd v1.0.1 // indirect + github.com/mitchellh/copystructure v1.2.0 // indirect + github.com/mitchellh/go-homedir v1.1.0 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/mitchellh/reflectwalk v1.0.2 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/mr-tron/base58 v1.2.0 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/mango v0.1.0 // indirect + github.com/muesli/mango-cobra v1.2.0 // indirect + github.com/muesli/mango-pflag v0.1.0 // indirect + github.com/muesli/roff v0.1.0 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/multiformats/go-base32 v0.1.0 // indirect + github.com/multiformats/go-base36 v0.2.0 // indirect + github.com/multiformats/go-multibase v0.2.0 // indirect + github.com/multiformats/go-multihash v0.2.3 // indirect + github.com/multiformats/go-varint v0.0.7 // indirect + github.com/ofabry/go-callvis v0.7.1 // indirect + github.com/oklog/ulid v1.3.1 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.0 // indirect + github.com/opentracing/opentracing-go v1.2.0 // indirect + github.com/pelletier/go-toml v1.9.5 // indirect + github.com/pelletier/go-toml/v2 v2.2.3 // indirect + github.com/pjbgf/sha1cd v0.3.0 // indirect + github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect + github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/sagikazarmark/locafero v0.6.0 // indirect + github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/sassoftware/relic v7.2.1+incompatible // indirect + github.com/scylladb/go-set v1.0.3-0.20200225121959-cc7b2070d91e // indirect + github.com/secure-systems-lab/go-securesystemslib v0.8.0 // indirect + github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect + github.com/shopspring/decimal v1.4.0 // indirect + github.com/sigstore/cosign/v2 v2.4.1 // indirect + github.com/sigstore/protobuf-specs v0.3.2 // indirect + github.com/sigstore/rekor v1.3.6 // indirect + github.com/sigstore/sigstore v1.8.9 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect + github.com/skeema/knownhosts v1.3.0 // indirect + github.com/slack-go/slack v0.16.0 // indirect + github.com/sourcegraph/conc v0.3.0 // indirect + github.com/spaolacci/murmur3 v1.1.0 // indirect + github.com/spf13/afero v1.11.0 // indirect + github.com/spf13/cast v1.7.0 // indirect + github.com/spf13/cobra v1.9.1 // indirect + github.com/spf13/pflag v1.0.6 // indirect + github.com/spf13/viper v1.19.0 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + github.com/theupdateframework/go-tuf v0.7.0 // indirect + github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399 // indirect + github.com/tomnomnom/linkheader v0.0.0-20180905144013-02ca5825eb80 // indirect + github.com/ulikunitz/xz v0.5.12 // indirect + github.com/vbatts/tar-split v0.11.6 // indirect + github.com/wagoodman/go-partybus v0.0.0-20230516145632-8ccac152c651 // indirect + github.com/wagoodman/go-progress v0.0.0-20220614130704-4b1c25a33c7c // indirect + github.com/whyrusleeping/cbor-gen v0.1.3-0.20240731173018-74d74643234c // indirect + github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect + github.com/xanzy/ssh-agent v0.3.3 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + gitlab.com/digitalxero/go-conventional-commit v1.0.7 // indirect + gitlab.com/gitlab-org/api/client-go v0.128.0 // indirect + go.mongodb.org/mongo-driver v1.14.0 // indirect + go.opencensus.io v0.24.0 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/contrib/detectors/gcp v1.35.0 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect + go.opentelemetry.io/otel v1.35.0 // indirect + go.opentelemetry.io/otel/metric v1.35.0 // indirect + go.opentelemetry.io/otel/sdk v1.35.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.35.0 // indirect + go.opentelemetry.io/otel/trace v1.35.0 // indirect + go.uber.org/atomic v1.11.0 // indirect + go.uber.org/automaxprocs v1.6.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.27.0 // indirect + gocloud.dev v0.41.0 // indirect + golang.org/x/crypto v0.37.0 // indirect + golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c // indirect + golang.org/x/image v0.15.0 // indirect + golang.org/x/mod v0.24.0 // indirect + golang.org/x/net v0.39.0 // indirect + golang.org/x/oauth2 v0.29.0 // indirect + golang.org/x/sync v0.13.0 // indirect + golang.org/x/sys v0.32.0 // indirect + golang.org/x/term v0.31.0 // indirect + golang.org/x/text v0.24.0 // indirect + golang.org/x/time v0.11.0 // indirect + golang.org/x/tools v0.32.0 // indirect + golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect + google.golang.org/api v0.228.0 // indirect + google.golang.org/genproto v0.0.0-20250324211829-b45e905df463 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250324211829-b45e905df463 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 // indirect + google.golang.org/grpc v1.71.0 // indirect + google.golang.org/protobuf v1.36.6 // indirect + gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect + gopkg.in/ini.v1 v1.67.0 // indirect + gopkg.in/mail.v2 v2.3.1 // indirect + gopkg.in/warnings.v0 v0.1.2 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + lukechampine.com/blake3 v1.2.1 // indirect + sigs.k8s.io/kind v0.24.0 // indirect + sigs.k8s.io/yaml v1.4.0 // indirect + software.sslmate.com/src/go-pkcs12 v0.5.0 // indirect +) diff --git a/tools.sum b/tools.sum new file mode 100644 index 000000000..1f7e1b9eb --- /dev/null +++ b/tools.sum @@ -0,0 +1,899 @@ +cel.dev/expr v0.22.1 h1:xoFEsNh972Yzey8N9TCPx2nDvMN7TMhQEzxLuj/iRrI= +cel.dev/expr v0.22.1/go.mod h1:MrpN08Q+lEBs+bGYdLxxHkZoUSsCp0nSKTs0nTymJgw= +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.120.0 h1:wc6bgG9DHyKqF5/vQvX1CiZrtHnxJjBlKUyF9nP6meA= +cloud.google.com/go v0.120.0/go.mod h1:/beW32s8/pGRuj4IILWQNd4uuebeT4dkOhKmkfit64Q= +cloud.google.com/go/auth v0.15.0 h1:Ly0u4aA5vG/fsSsxu98qCQBemXtAtJf+95z9HK+cxps= +cloud.google.com/go/auth v0.15.0/go.mod h1:WJDGqZ1o9E9wKIL+IwStfyn/+s59zl4Bi+1KQNVXLZ8= +cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= +cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= +cloud.google.com/go/compute/metadata v0.6.0 h1:A6hENjEsCDtC1k8byVsgwvVcioamEHvZ4j01OwKxG9I= +cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg= +cloud.google.com/go/iam v1.4.2 h1:4AckGYAYsowXeHzsn/LCKWIwSWLkdb0eGjH8wWkd27Q= +cloud.google.com/go/iam v1.4.2/go.mod h1:REGlrt8vSlh4dfCJfSEcNjLGq75wW75c5aU3FLOYq34= +cloud.google.com/go/kms v1.21.1 h1:r1Auo+jlfJSf8B7mUnVw5K0fI7jWyoUy65bV53VjKyk= +cloud.google.com/go/kms v1.21.1/go.mod h1:s0wCyByc9LjTdCjG88toVs70U9W+cc6RKFc8zAqX7nE= +cloud.google.com/go/longrunning v0.6.6 h1:XJNDo5MUfMM05xK3ewpbSdmt7R2Zw+aQEMbdQR65Rbw= +cloud.google.com/go/longrunning v0.6.6/go.mod h1:hyeGJUrPHcx0u2Uu1UFSoYZLn4lkMrccJig0t4FI7yw= +cloud.google.com/go/monitoring v1.24.1 h1:vKiypZVFD/5a3BbQMvI4gZdl8445ITzXFh257XBgrS0= +cloud.google.com/go/monitoring v1.24.1/go.mod h1:Z05d1/vn9NaujqY2voG6pVQXoJGbp+r3laV+LySt9K0= +cloud.google.com/go/storage v1.51.0 h1:ZVZ11zCiD7b3k+cH5lQs/qcNaoSz3U9I0jgwVzqDlCw= +cloud.google.com/go/storage v1.51.0/go.mod h1:YEJfu/Ki3i5oHC/7jyTgsGZwdQ8P9hqMqvpi5kRKGgc= +code.gitea.io/sdk/gitea v0.21.0 h1:69n6oz6kEVHRo1+APQQyizkhrZrLsTLXey9142pfkD4= +code.gitea.io/sdk/gitea v0.21.0/go.mod h1:tnBjVhuKJCn8ibdyyhvUyxrR1Ca2KHEoTWoukNhXQPA= +dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= +dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +github.com/42wim/httpsig v1.2.2 h1:ofAYoHUNs/MJOLqQ8hIxeyz2QxOz8qdSVvp3PX/oPgA= +github.com/42wim/httpsig v1.2.2/go.mod h1:P/UYo7ytNBFwc+dg35IubuAUIs8zj5zzFIgUCEl55WY= +github.com/AlekSi/pointer v1.2.0 h1:glcy/gc4h8HnG2Z3ZECSzZ1IX1x2JxRVuDzaJwQE0+w= +github.com/AlekSi/pointer v1.2.0/go.mod h1:gZGfd3dpW4vEc/UlyfKKi1roIqcCgwOIvb0tSNSBle0= +github.com/Azure/azure-sdk-for-go v68.0.0+incompatible h1:fcYLmCpyNYRnvJbPerq7U0hS+6+I79yEDJBqVNcqUzU= +github.com/Azure/azure-sdk-for-go v68.0.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.1 h1:DSDNVxqkoXJiko6x8a90zidoYqnYYa6c1MTzDKzKkTo= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.1/go.mod h1:zGqV2R4Cr/k8Uye5w+dgQ06WJtEcbQG/8J7BB6hnCr4= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.8.2 h1:F0gBpfdPLGsw+nsgk6aqqkZS1jiixa5WwFe3fk/T3Ys= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.8.2/go.mod h1:SqINnQ9lVVdRlyC8cd1lCI0SdX4n2paeABd2K8ggfnE= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 h1:ywEEhmNahHBihViHepv3xPBn1663uRv2t2q/ESv9seY= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0/go.mod h1:iZDifYGJTIgIIkYRNWPENUnqx6bJ2xnSDFI2tjwZNuY= +github.com/Azure/azure-sdk-for-go/sdk/keyvault/azkeys v0.10.0 h1:m/sWOGCREuSBqg2htVQTBY8nOZpyajYztF0vUvSZTuM= +github.com/Azure/azure-sdk-for-go/sdk/keyvault/azkeys v0.10.0/go.mod h1:Pu5Zksi2KrU7LPbZbNINx6fuVrUp/ffvpxdDj+i8LeE= +github.com/Azure/azure-sdk-for-go/sdk/keyvault/internal v0.7.1 h1:FbH3BbSb4bvGluTesZZ+ttN/MDsnMmQP36OSnDuSXqw= +github.com/Azure/azure-sdk-for-go/sdk/keyvault/internal v0.7.1/go.mod h1:9V2j0jn9jDEkCkv8w/bKTNppX/d0FVA1ud77xCIP4KA= +github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.0 h1:UXT0o77lXQrikd1kgwIPQOUect7EoR/+sbP4wQKdzxM= +github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.0/go.mod h1:cTvi54pg19DoT07ekoeMgE/taAwNtCShVeZqA+Iv2xI= +github.com/Azure/go-autorest v14.2.0+incompatible h1:V5VMDjClD3GiElqLWO7mz2MxNAK/vTfRHdAubSIPRgs= +github.com/Azure/go-autorest v14.2.0+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24= +github.com/Azure/go-autorest/autorest v0.11.24/go.mod h1:G6kyRlFnTuSbEYkQGawPfsCswgme4iYf6rfSKUDzbCc= +github.com/Azure/go-autorest/autorest v0.11.29 h1:I4+HL/JDvErx2LjyzaVxllw2lRDB5/BT2Bm4g20iqYw= +github.com/Azure/go-autorest/autorest v0.11.29/go.mod h1:ZtEzC4Jy2JDrZLxvWs8LrBWEBycl1hbT1eknI8MtfAs= +github.com/Azure/go-autorest/autorest/adal v0.9.18/go.mod h1:XVVeme+LZwABT8K5Lc3hA4nAe8LDBVle26gTrguhhPQ= +github.com/Azure/go-autorest/autorest/adal v0.9.22/go.mod h1:XuAbAEUv2Tta//+voMI038TrJBqjKam0me7qR+L8Cmk= +github.com/Azure/go-autorest/autorest/adal v0.9.23 h1:Yepx8CvFxwNKpH6ja7RZ+sKX+DWYNldbLiALMC3BTz8= +github.com/Azure/go-autorest/autorest/adal v0.9.23/go.mod h1:5pcMqFkdPhviJdlEy3kC/v1ZLnQl0MH6XA5YCcMhy4c= +github.com/Azure/go-autorest/autorest/azure/auth v0.5.12 h1:wkAZRgT/pn8HhFyzfe9UnqOjJYqlembgCTi72Bm/xKk= +github.com/Azure/go-autorest/autorest/azure/auth v0.5.12/go.mod h1:84w/uV8E37feW2NCJ08uT9VBfjfUHpgLVnG2InYD6cg= +github.com/Azure/go-autorest/autorest/azure/cli v0.4.5/go.mod h1:ADQAXrkgm7acgWVUNamOgh8YNrv4p27l3Wc55oVfpzg= +github.com/Azure/go-autorest/autorest/azure/cli v0.4.6 h1:w77/uPk80ZET2F+AfQExZyEWtn+0Rk/uw17m9fv5Ajc= +github.com/Azure/go-autorest/autorest/azure/cli v0.4.6/go.mod h1:piCfgPho7BiIDdEQ1+g4VmKyD5y+p/XtSNqE6Hc4QD0= +github.com/Azure/go-autorest/autorest/date v0.3.0 h1:7gUk1U5M/CQbp9WoqinNzJar+8KY+LPI6wiWrP/myHw= +github.com/Azure/go-autorest/autorest/date v0.3.0/go.mod h1:BI0uouVdmngYNUzGWeSYnokU+TrmwEsOqdt8Y6sso74= +github.com/Azure/go-autorest/autorest/mocks v0.4.1/go.mod h1:LTp+uSrOhSkaKrUy935gNZuuIPPVsHlr9DSOxSayd+k= +github.com/Azure/go-autorest/autorest/mocks v0.4.2/go.mod h1:Vy7OitM9Kei0i1Oj+LvyAWMXJHeKH1MVlzFugfVrmyU= +github.com/Azure/go-autorest/autorest/to v0.4.1 h1:CxNHBqdzTr7rLtdrtb5CMjJcDut+WNGCVv7OmS5+lTc= +github.com/Azure/go-autorest/autorest/to v0.4.1/go.mod h1:EtaofgU4zmtvn1zT2ARsjRFdq9vXx0YWtmElwL+GZ9M= +github.com/Azure/go-autorest/logger v0.2.1 h1:IG7i4p/mDa2Ce4TRyAO8IHnVhAVF3RFU+ZtXWSmf4Tg= +github.com/Azure/go-autorest/logger v0.2.1/go.mod h1:T9E3cAhj2VqvPOtCYAvby9aBXkZmbF5NWuPV8+WeEW8= +github.com/Azure/go-autorest/tracing v0.6.0 h1:TYi4+3m5t6K48TGI9AUdb+IzbnSxvnvUMfuitfgcfuo= +github.com/Azure/go-autorest/tracing v0.6.0/go.mod h1:+vhtPC754Xsa23ID7GlGsrdKBpUA79WCAKPPZVC2DeU= +github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJe7PpYPXT5A29ZkwJaPqcva7BVeemZOZs= +github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= +github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.27.0 h1:ErKg/3iS1AKcTkf3yixlZ54f9U1rljCkQyEXWUnIUxc= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.27.0/go.mod h1:yAZHSGnqScoU556rBOVkwLze6WP5N+U11RHuWaGVxwY= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.0 h1:fYE9p3esPxA/C0rQ0AHhP0drtPXDRhaWiwg1DPqO7IU= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.0/go.mod h1:BnBReJLvVYx2CS/UHOgVz2BXKXD9wsQPxZug20nZhd0= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0 h1:6/0iUd0xrnX7qt+mLNRwg5c0PGv8wpE8K90ryANQwMI= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0/go.mod h1:otE2jQekW/PqXk1Awf5lmfokJx4uwuqcj1ab5SpGeW0= +github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= +github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= +github.com/Masterminds/semver/v3 v3.3.1 h1:QtNSWtVZ3nBfk8mAOu/B6v7FMJ+NHTIgUPi7rj+4nv4= +github.com/Masterminds/semver/v3 v3.3.1/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs= +github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0= +github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw= +github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= +github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM= +github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU= +github.com/alessio/shellescape v1.4.2 h1:MHPfaU+ddJ0/bYWpgIeUnQUqKrlJ1S7BfEYPM4uEoM0= +github.com/alessio/shellescape v1.4.2/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30= +github.com/anchore/bubbly v0.0.0-20241107060245-f2a5536f366a h1:smr1CcMkgeMd6G75N+2OVNk/uHbX/WLR0bk+kMWEyr8= +github.com/anchore/bubbly v0.0.0-20241107060245-f2a5536f366a/go.mod h1:P5IrP8AhuzApVKa5H7k2hHX5pZA1uhyi+Z1VjK1EtA4= +github.com/anchore/go-logger v0.0.0-20241005132348-65b4486fbb28 h1:TKlTOayTJKpoLPJbeMykEwxCn0enACf06u0RSIdFG5w= +github.com/anchore/go-logger v0.0.0-20241005132348-65b4486fbb28/go.mod h1:5iJIa34inbIEFRwoWxNBTnjzIcl4G3le1LppPDmpg/4= +github.com/anchore/go-macholibre v0.0.0-20220308212642-53e6d0aaf6fb h1:iDMnx6LIjtjZ46C0akqveX83WFzhpTD3eqOthawb5vU= +github.com/anchore/go-macholibre v0.0.0-20220308212642-53e6d0aaf6fb/go.mod h1:DmTY2Mfcv38hsHbG78xMiTDdxFtkHpgYNVDPsF2TgHk= +github.com/anchore/quill v0.5.1 h1:+TAJroWuMC0AofI4gD9V9v65zR8EfKZg8u+ZD+dKZS4= +github.com/anchore/quill v0.5.1/go.mod h1:tAzfFxVluL2P1cT+xEy+RgQX1hpNuliUC5dTYSsnCLQ= +github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= +github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= +github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= +github.com/atc0005/go-teams-notify/v2 v2.13.0 h1:nbDeHy89NjYlF/PEfLVF6lsserY9O5SnN1iOIw3AxXw= +github.com/atc0005/go-teams-notify/v2 v2.13.0/go.mod h1:WSv9moolRsBcpZbwEf6gZxj7h0uJlJskJq5zkEWKO8Y= +github.com/aws/aws-sdk-go v1.55.6 h1:cSg4pvZ3m8dgYcgqB97MrcdjUmZ1BeMYKUxMMB89IPk= +github.com/aws/aws-sdk-go v1.55.6/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU= +github.com/aws/aws-sdk-go-v2 v1.36.3 h1:mJoei2CxPutQVxaATCzDUjcZEjVRdpsiiXi2o38yqWM= +github.com/aws/aws-sdk-go-v2 v1.36.3/go.mod h1:LLXuLpgzEbD766Z5ECcRmi8AzSwfZItDtmABVkRLGzg= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10 h1:zAybnyUQXIZ5mok5Jqwlf58/TFE7uvd3IAsa1aF9cXs= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10/go.mod h1:qqvMj6gHLR/EXWZw4ZbqlPbQUyenf4h82UQUlKc+l14= +github.com/aws/aws-sdk-go-v2/config v1.29.12 h1:Y/2a+jLPrPbHpFkpAAYkVEtJmxORlXoo5k2g1fa2sUo= +github.com/aws/aws-sdk-go-v2/config v1.29.12/go.mod h1:xse1YTjmORlb/6fhkWi8qJh3cvZi4JoVNhc+NbJt4kI= +github.com/aws/aws-sdk-go-v2/credentials v1.17.65 h1:q+nV2yYegofO/SUXruT+pn4KxkxmaQ++1B/QedcKBFM= +github.com/aws/aws-sdk-go-v2/credentials v1.17.65/go.mod h1:4zyjAuGOdikpNYiSGpsGz8hLGmUzlY8pc8r9QQ/RXYQ= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30 h1:x793wxmUWVDhshP8WW2mlnXuFrO4cOd3HLBroh1paFw= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30/go.mod h1:Jpne2tDnYiFascUEs2AWHJL9Yp7A5ZVy3TNyxaAjD6M= +github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.69 h1:6VFPH/Zi9xYFMJKPQOX5URYkQoXRWeJ7V/7Y6ZDYoms= +github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.69/go.mod h1:GJj8mmO6YT6EqgduWocwhMoxTLFitkhIrK+owzrYL2I= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 h1:ZK5jHhnrioRkUNOc+hOgQKlUL5JeC3S6JgLxtQ+Rm0Q= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34/go.mod h1:p4VfIceZokChbA9FzMbRGz5OV+lekcVtHlPKEO0gSZY= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 h1:SZwFm17ZUNNg5Np0ioo/gq8Mn6u9w19Mri8DnJ15Jf0= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34/go.mod h1:dFZsC0BLo346mvKQLWmoJxT+Sjp+qcVR1tRVHQGOH9Q= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.34 h1:ZNTqv4nIdE/DiBfUUfXcLZ/Spcuz+RjeziUtNJackkM= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.34/go.mod h1:zf7Vcd1ViW7cPqYWEHLHJkS50X0JS2IKz9Cgaj6ugrs= +github.com/aws/aws-sdk-go-v2/service/ecr v1.40.3 h1:a+210FCU/pR5hhKRaskRfX/ogcyyzFBrehcTk5DTAyU= +github.com/aws/aws-sdk-go-v2/service/ecr v1.40.3/go.mod h1:dtD3a4sjUjVL86e0NUvaqdGvds5ED6itUiZPDaT+Gh8= +github.com/aws/aws-sdk-go-v2/service/ecrpublic v1.31.2 h1:E6/Myrj9HgLF22medmDrKmbpm4ULsa+cIBNx3phirBk= +github.com/aws/aws-sdk-go-v2/service/ecrpublic v1.31.2/go.mod h1:OQ8NALFcchBJ/qruak6zKUQodovnTKKaReTuCkc5/9Y= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 h1:eAh2A4b5IzM/lum78bZ590jy36+d/aFLgKF/4Vd1xPE= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3/go.mod h1:0yKJC/kb8sAnmlYa6Zs3QVYqaC8ug2AbnNChv5Ox3uA= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.0 h1:lguz0bmOoGzozP9XfRJR1QIayEYo+2vP/No3OfLF0pU= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.0/go.mod h1:iu6FSzgt+M2/x3Dk8zhycdIcHjEFb36IS8HVUVFoMg0= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 h1:dM9/92u2F1JbDaGooxTq18wmmFzbJRfXfVfy96/1CXM= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15/go.mod h1:SwFBy2vjtA0vZbjjaFtfN045boopadnoVPhu4Fv66vY= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.15 h1:moLQUoVq91LiqT1nbvzDukyqAlCv89ZmwaHw/ZFlFZg= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.15/go.mod h1:ZH34PJUc8ApjBIfgQCFvkWcUDBtl/WTD+uiYHjd8igA= +github.com/aws/aws-sdk-go-v2/service/kms v1.38.1 h1:tecq7+mAav5byF+Mr+iONJnCBf4B4gon8RSp4BrweSc= +github.com/aws/aws-sdk-go-v2/service/kms v1.38.1/go.mod h1:cQn6tAF77Di6m4huxovNM7NVAozWTZLsDRp9t8Z/WYk= +github.com/aws/aws-sdk-go-v2/service/s3 v1.78.2 h1:jIiopHEV22b4yQP2q36Y0OmwLbsxNWdWwfZRR5QRRO4= +github.com/aws/aws-sdk-go-v2/service/s3 v1.78.2/go.mod h1:U5SNqwhXB3Xe6F47kXvWihPl/ilGaEDe8HD/50Z9wxc= +github.com/aws/aws-sdk-go-v2/service/sso v1.25.2 h1:pdgODsAhGo4dvzC3JAG5Ce0PX8kWXrTZGx+jxADD+5E= +github.com/aws/aws-sdk-go-v2/service/sso v1.25.2/go.mod h1:qs4a9T5EMLl/Cajiw2TcbNt2UNo/Hqlyp+GiuG4CFDI= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.0 h1:90uX0veLKcdHVfvxhkWUQSCi5VabtwMLFutYiRke4oo= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.0/go.mod h1:MlYRNmYu/fGPoxBQVvBYr9nyr948aY/WLUvwBMBJubs= +github.com/aws/aws-sdk-go-v2/service/sts v1.33.17 h1:PZV5W8yk4OtH1JAuhV2PXwwO9v5G5Aoj+eMCn4T+1Kc= +github.com/aws/aws-sdk-go-v2/service/sts v1.33.17/go.mod h1:cQnB8CUnxbMU82JvlqjKR2HBOm3fe9pWorWBza6MBJ4= +github.com/aws/smithy-go v1.22.3 h1:Z//5NuZCSW6R4PhQ93hShNbyBbn8BWCmCVCt+Q8Io5k= +github.com/aws/smithy-go v1.22.3/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI= +github.com/awslabs/amazon-ecr-credential-helper/ecr-login v0.9.1 h1:50sS0RWhGpW/yZx2KcDNEb1u1MANv5BMEkJgcieEDTA= +github.com/awslabs/amazon-ecr-credential-helper/ecr-login v0.9.1/go.mod h1:ErZOtbzuHabipRTDTor0inoRlYwbsV1ovwSxjGs/uJo= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= +github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= +github.com/blacktop/go-dwarf v1.0.10 h1:i9zYgcIROETsNZ6V+zZn3uDH21FCG5BLLZ837GitxS0= +github.com/blacktop/go-dwarf v1.0.10/go.mod h1:4W2FKgSFYcZLDwnR7k+apv5i3nrau4NGl9N6VQ9DSTo= +github.com/blacktop/go-macho v1.1.238 h1:OFfT6NB/SWxkoky7L/ytuY8QekgFpa9pmz/GHUQLsmM= +github.com/blacktop/go-macho v1.1.238/go.mod h1:dtlW2AJKQpFzImBVPWiUKZ6OxrQ2MLfWi/BPPe0EONE= +github.com/blakesmith/ar v0.0.0-20190502131153-809d4375e1fb h1:m935MPodAbYS46DG4pJSv7WO+VECIWUQ7OJYSoTrMh4= +github.com/blakesmith/ar v0.0.0-20190502131153-809d4375e1fb/go.mod h1:PkYb9DJNAwrSvRx5DYA+gUcOIgTGVMNkfSCbZM8cWpI= +github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ= +github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= +github.com/bluesky-social/indigo v0.0.0-20240813042137-4006c0eca043 h1:927VIkxPFKpfJKVDtCNgSQtlhksARaLvsLxppR2FukM= +github.com/bluesky-social/indigo v0.0.0-20240813042137-4006c0eca043/go.mod h1:dXjdzg6bhg1JKnKuf6EBJTtcxtfHYBFEe9btxX5YeAE= +github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= +github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= +github.com/caarlos0/ctrlc v1.2.0 h1:AtbThhmbeYx1WW3WXdWrd94EHKi+0NPRGS4/4pzrjwk= +github.com/caarlos0/ctrlc v1.2.0/go.mod h1:n3gDlSjsXZ7rbD9/RprIR040b7oaLfNStikPd4gFago= +github.com/caarlos0/env/v11 v11.3.1 h1:cArPWC15hWmEt+gWk7YBi7lEXTXCvpaSdCiZE2X5mCA= +github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U= +github.com/caarlos0/go-reddit/v3 v3.0.1 h1:w8ugvsrHhaE/m4ez0BO/sTBOBWI9WZTjG7VTecHnql4= +github.com/caarlos0/go-reddit/v3 v3.0.1/go.mod h1:QlwgmG5SAqxMeQvg/A2dD1x9cIZCO56BMnMdjXLoisI= +github.com/caarlos0/go-shellwords v1.0.12 h1:HWrUnu6lGbWfrDcFiHcZiwOLzHWjjrPVehULaTFgPp8= +github.com/caarlos0/go-shellwords v1.0.12/go.mod h1:bYeeX1GrTLPl5cAMYEzdm272qdsQAZiaHgeF0KTk1Gw= +github.com/caarlos0/go-version v0.2.0 h1:TTD5dF3PBAtRHbfCKRE173SrVVpbE0yX95EDQ4BwTGs= +github.com/caarlos0/go-version v0.2.0/go.mod h1:X+rI5VAtJDpcjCjeEIXpxGa5+rTcgur1FK66wS0/944= +github.com/caarlos0/log v0.4.8 h1:k2URuG28jxzVUSltOjY1qy0zmCNVhMeNr8cP5P/2jB4= +github.com/caarlos0/log v0.4.8/go.mod h1:oGfAH1ldO3nYYrbXtofO6y2K/QTPF/VaGMFmD/LRa+M= +github.com/caarlos0/testfs v0.4.4/go.mod h1:bRN55zgG4XCUVVHZCeU+/Tz1Q6AxEJOEJTliBy+1DMk= +github.com/carlmjohnson/versioninfo v0.22.5 h1:O00sjOLUAFxYQjlN/bzYTuZiS0y6fWDQjMRvwtKgwwc= +github.com/carlmjohnson/versioninfo v0.22.5/go.mod h1:QT9mph3wcVfISUKd0i9sZfVrPviHuSF+cUtLjm2WSf8= +github.com/cavaliergopher/cpio v1.0.1 h1:KQFSeKmZhv0cr+kawA3a0xTQCU4QxXF1vhU7P7av2KM= +github.com/cavaliergopher/cpio v1.0.1/go.mod h1:pBdaqQjnvXxdS/6CvNDwIANIFSP0xRKI16PX4xejRQc= +github.com/cenkalti/backoff/v4 v4.1.2/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInqkPWOWmG2CLw= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/certifi/gocertifi v0.0.0-20180118203423-deb3ae2ef261/go.mod h1:GJKEexRPVJrBSOjoqN5VNOIKJ5Q3RViH6eu3puDRwx4= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/charmbracelet/bubbletea v1.3.0 h1:fPMyirm0u3Fou+flch7hlJN9krlnVURrkUVDwqXjoAc= +github.com/charmbracelet/bubbletea v1.3.0/go.mod h1:eTaHfqbIwvBhFQM/nlT1NsGc4kp8jhF8LfUK67XiTDM= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= +github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/chrismellard/docker-credential-acr-env v0.0.0-20230304212654-82a0ddb27589 h1:krfRl01rzPzxSxyLyrChD+U+MzsBXbm0OwYYB67uF+4= +github.com/chrismellard/docker-credential-acr-env v0.0.0-20230304212654-82a0ddb27589/go.mod h1:OuDyvmLnMCwa2ep4Jkm6nyA0ocJuZlGyk2gGseVzERM= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cloudflare/circl v1.3.8 h1:j+V8jJt09PoeMFIu2uh5JUyEaIHTXVOHslFoLNAKqwI= +github.com/cloudflare/circl v1.3.8/go.mod h1:PDRU+oXvdD7KCtgKxW95M5Z8BpSCJXQORiZFnBQS5QU= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/cncf/xds/go v0.0.0-20250326154945-ae57f3c0d45f h1:C5bqEmzEPLsHm9Mv73lSE9e9bKV23aB1vxOsmZrkl3k= +github.com/cncf/xds/go v0.0.0-20250326154945-ae57f3c0d45f/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8= +github.com/containerd/stargz-snapshotter/estargz v0.16.3 h1:7evrXtoh1mSbGj/pfRccTampEyKpjpOnS3CyiV1Ebr8= +github.com/containerd/stargz-snapshotter/estargz v0.16.3/go.mod h1:uyr4BfYfOj3G9WBVE8cOlQmXAbPN9VEQpBBeJIuOipU= +github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= +github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/cyberphone/json-canonicalization v0.0.0-20231011164504-785e29786b46 h1:2Dx4IHfC1yHWI12AxQDJM1QbRCDfk6M+blLzlZCXdrc= +github.com/cyberphone/json-canonicalization v0.0.0-20231011164504-785e29786b46/go.mod h1:uzvlm1mxhHkdfqitSA92i7Se+S9ksOn3a3qmv/kyOCw= +github.com/cyphar/filepath-securejoin v0.3.6 h1:4d9N5ykBnSp5Xn2JkhocYDkOpURL/18CYMpo6xB9uWM= +github.com/cyphar/filepath-securejoin v0.3.6/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= +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/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0= +github.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE= +github.com/dghubble/go-twitter v0.0.0-20211115160449-93a8679adecb h1:7ENzkH+O3juL+yj2undESLTaAeRllHwCs/b8z6aWSfc= +github.com/dghubble/go-twitter v0.0.0-20211115160449-93a8679adecb/go.mod h1:qhZBgV9e4WyB1JNjHpcXVkUe3knWUwYuAPB1hITdm50= +github.com/dghubble/oauth1 v0.7.3 h1:EkEM/zMDMp3zOsX2DC/ZQ2vnEX3ELK0/l9kb+vs4ptE= +github.com/dghubble/oauth1 v0.7.3/go.mod h1:oxTe+az9NSMIucDPDCCtzJGsPhciJV33xocHfcR2sVY= +github.com/dghubble/sling v1.4.0 h1:/n8MRosVTthvMbwlNZgLx579OGVjUOy3GNEv5BIqAWY= +github.com/dghubble/sling v1.4.0/go.mod h1:0r40aNsU9EdDUVBNhfCstAtFgutjgJGYbO1oNzkMoM8= +github.com/dimchansky/utfbom v1.1.1 h1:vV6w1AhK4VMnhBno/TPVCoK9U/LP0PkLCS9tbxHdi/U= +github.com/dimchansky/utfbom v1.1.1/go.mod h1:SxdoEBH5qIqFocHMyGOXVAybYJdr71b1Q/j0mACtrfE= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/cli v27.5.0+incompatible h1:aMphQkcGtpHixwwhAXJT1rrK/detk2JIvDaFkLctbGM= +github.com/docker/cli v27.5.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= +github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk= +github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= +github.com/docker/docker v27.5.0+incompatible h1:um++2NcQtGRTz5eEgO6aJimo6/JxrTXC941hd05JO6U= +github.com/docker/docker v27.5.0+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/docker-credential-helpers v0.8.2 h1:bX3YxiGzFP5sOXWc3bTPEXdEaZSeVMrFgOr3T+zrFAo= +github.com/docker/docker-credential-helpers v0.8.2/go.mod h1:P3ci7E3lwkZg6XiHdRKft1KckHiO9a2rNtyFbZ/ry9M= +github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= +github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/elliotchance/orderedmap/v2 v2.7.0 h1:WHuf0DRo63uLnldCPp9ojm3gskYwEdIIfAUVG5KhoOc= +github.com/elliotchance/orderedmap/v2 v2.7.0/go.mod h1:85lZyVbpGaGvHvnKa7Qhx7zncAdBIBq6u56Hb1PRU5Q= +github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= +github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/go-control-plane/envoy v1.32.4 h1:jb83lalDRZSpPWW2Z7Mck/8kXZ5CQAFYVjQcdVIr83A= +github.com/envoyproxy/go-control-plane/envoy v1.32.4/go.mod h1:Gzjc5k8JcJswLjAx1Zm+wSYE20UrLtt7JZMWiWQXQEw= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8= +github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/evanphx/json-patch/v5 v5.6.0 h1:b91NhWfaz02IuVxO9faSllyAtNXHMPkC5J8sJCLunww= +github.com/evanphx/json-patch/v5 v5.6.0/go.mod h1:G79N1coSVB93tBe7j6PhzjmR3/2VvlbKOFpnXhI9Bw4= +github.com/fatih/set v0.2.1/go.mod h1:+RKtMCH+favT2+3YecHGxcc0b4KyVWA1QWWJUs4E0CI= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8= +github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= +github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= +github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= +github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= +github.com/github/smimesign v0.2.0 h1:Hho4YcX5N1I9XNqhq0fNx0Sts8MhLonHd+HRXVGNjvk= +github.com/github/smimesign v0.2.0/go.mod h1:iZiiwNT4HbtGRVqCQu7uJPEZCuEE5sfSSttcnePkDl4= +github.com/go-chi/chi v4.1.2+incompatible h1:fGFk2Gmi/YKXk0OmGfBh0WgmN3XB8lVnEyNz34tQRec= +github.com/go-chi/chi v4.1.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ= +github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI= +github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= +github.com/go-git/go-billy/v5 v5.6.1 h1:u+dcrgaguSSkbjzHwelEjc0Yj300NUevrrPphk/SoRA= +github.com/go-git/go-billy/v5 v5.6.1/go.mod h1:0AsLr1z2+Uksi4NlElmMblP5rPcDZNRCD8ujZCRR2BE= +github.com/go-git/go-git/v5 v5.13.1 h1:DAQ9APonnlvSWpvolXWIuV6Q6zXy2wHbN4cVlNR5Q+M= +github.com/go-git/go-git/v5 v5.13.1/go.mod h1:qryJB4cSBoq3FRoBRf5A77joojuBcmPJ0qu3XXXVixc= +github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE= +github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-openapi/analysis v0.23.0 h1:aGday7OWupfMs+LbmLZG4k0MYXIANxcuBTYUC03zFCU= +github.com/go-openapi/analysis v0.23.0/go.mod h1:9mz9ZWaSlV8TvjQHLl2mUW2PbZtemkE8yA5v22ohupo= +github.com/go-openapi/errors v0.22.0 h1:c4xY/OLxUBSTiepAg3j/MHuAv5mJhnf53LLMWFB+u/w= +github.com/go-openapi/errors v0.22.0/go.mod h1:J3DmZScxCDufmIMsdOuDHxJbdOGC0xtUynjIx092vXE= +github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= +github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= +github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= +github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4= +github.com/go-openapi/loads v0.22.0 h1:ECPGd4jX1U6NApCGG1We+uEozOAvXvJSF4nnwHZ8Aco= +github.com/go-openapi/loads v0.22.0/go.mod h1:yLsaTCS92mnSAZX5WWoxszLj0u+Ojl+Zs5Stn1oF+rs= +github.com/go-openapi/runtime v0.28.0 h1:gpPPmWSNGo214l6n8hzdXYhPuJcGtziTOgUpvsFWGIQ= +github.com/go-openapi/runtime v0.28.0/go.mod h1:QN7OzcS+XuYmkQLw05akXk0jRH/eZ3kb18+1KwW9gyc= +github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9ZY= +github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk= +github.com/go-openapi/strfmt v0.23.0 h1:nlUS6BCqcnAk0pyhi9Y+kdDVZdZMHfEKQiS4HaMgO/c= +github.com/go-openapi/strfmt v0.23.0/go.mod h1:NrtIpfKtWIygRkKVsxh7XQMDQW5HKQl6S5ik2elW+K4= +github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= +github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/go-openapi/validate v0.24.0 h1:LdfDKwNbpB6Vn40xhTdNZAnfLECL81w+VX3BumrGD58= +github.com/go-openapi/validate v0.24.0/go.mod h1:iyeX1sEufmv3nPbBdX3ieNviWnOZaJ1+zquzJEf2BAQ= +github.com/go-restruct/restruct v1.2.0-alpha h1:2Lp474S/9660+SJjpVxoKuWX09JsXHSrdV7Nv3/gkvc= +github.com/go-restruct/restruct v1.2.0-alpha/go.mod h1:KqrpKpn4M8OLznErihXTGLlsXFGeLxHUrLRRI/1YjGk= +github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 h1:wG8n/XJQ07TmjbITcGiUaOtXxdrINDz1b0J1w0SzqDc= +github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8= +github.com/go-test/deep v1.0.4/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= +github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0= +github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= +github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= +github.com/goccy/go-graphviz v0.1.2 h1:sWSJ6w13BCm/ZOUTHDVrdvbsxqN8yyzaFcHrH/hQ9Yg= +github.com/goccy/go-graphviz v0.1.2/go.mod h1:pMYpbAqJT10V8dzV1JN/g/wUlG/0imKPzn3ZsrchGCI= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt/v4 v4.0.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg= +github.com/golang-jwt/jwt/v4 v4.2.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg= +github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= +github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= +github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= +github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-containerregistry v0.20.3 h1:oNx7IdTI936V8CQRveCjaxOiegWwvM7kqkbXTpyiovI= +github.com/google/go-containerregistry v0.20.3/go.mod h1:w00pIgBRDVUDFM6bq+Qx8lwNWK+cxgCuX1vd3PIBDNI= +github.com/google/go-github/v71 v71.0.0 h1:Zi16OymGKZZMm8ZliffVVJ/Q9YZreDKONCr+WUd0Z30= +github.com/google/go-github/v71 v71.0.0/go.mod h1:URZXObp2BLlMjwu0O8g4y6VBneUj2bCHgnI8FfgZ51M= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/google/ko v0.17.1 h1:CIV2w1tFTm7wrhs/GHpegUwSmnEcynBr/Us9kgtK5NY= +github.com/google/ko v0.17.1/go.mod h1:79yvkOlGy4Kxw9XPfRWpqJXvgEPqAM8jTSp7itqv71o= +github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/google/rpmpack v0.6.1-0.20240329070804-c2247cbb881a h1:JJBdjSfqSy3mnDT0940ASQFghwcZ4y4cb6ttjAoXqwE= +github.com/google/rpmpack v0.6.1-0.20240329070804-c2247cbb881a/go.mod h1:uqVAUVQLq8UY2hCDfmJ/+rtO3aw7qyhc90rCVEabEfI= +github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= +github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= +github.com/google/safetext v0.0.0-20220905092116-b49f7bc46da2 h1:SJ+NtwL6QaZ21U+IrK7d0gGgpjGGvd2kz+FzTHVzdqI= +github.com/google/safetext v0.0.0-20220905092116-b49f7bc46da2/go.mod h1:Tv1PlzqC9t8wNnpPdctvtSUOPUUg4SHeE6vR1Ir2hmg= +github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= +github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/wire v0.6.0 h1:HBkoIh4BdSxoyo9PveV8giw7ZsaBOvzWKfcg/6MrVwI= +github.com/google/wire v0.6.0/go.mod h1:F4QhpQ9EDIdJ1Mbop/NZBRB+5yrR6qg3BnctaoUk6NA= +github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU9uHLo7OnF5tL52HFAgMmyrf4= +github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= +github.com/googleapis/gax-go/v2 v2.14.1 h1:hb0FFeiPaQskmvakKu5EbCbpntQn48jyHuvrkurSS/Q= +github.com/googleapis/gax-go/v2 v2.14.1/go.mod h1:Hb/NubMaVM88SrNkvl8X/o8XWwDJEPqouaLeN2IUxoA= +github.com/gookit/color v1.2.5/go.mod h1:AhIE+pS6D4Ql0SQWbBeXPHw7gY0/sjHoA4s/n1KB7xg= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/goreleaser/chglog v0.6.2 h1:qroqdMHzwoAPTHHzJtbCfYbwg/yWJrNQApZ6IQAq8bU= +github.com/goreleaser/chglog v0.6.2/go.mod h1:BP0xQQc6B8aM+4dhvSLlVTv0rvhuOF0JacDO1+h7L3U= +github.com/goreleaser/fileglob v1.3.0 h1:/X6J7U8lbDpQtBvGcwwPS6OpzkNVlVEsFUVRx9+k+7I= +github.com/goreleaser/fileglob v1.3.0/go.mod h1:Jx6BoXv3mbYkEzwm9THo7xbr5egkAraxkGorbJb4RxU= +github.com/goreleaser/goreleaser/v2 v2.9.0 h1:SfL7IsuiG+2BY6/pQwV1gzXfkzVboI6NQP7zk/ixcjY= +github.com/goreleaser/goreleaser/v2 v2.9.0/go.mod h1:52353EQivICZ9Y8UdwJhzrktSf/zeBc5X2nPWJZLIuU= +github.com/goreleaser/nfpm/v2 v2.41.3 h1:IRRsqv5NgiCKUy57HjQgfVBFb44VH8+r1mWeEF8OuA4= +github.com/goreleaser/nfpm/v2 v2.41.3/go.mod h1:0t54RfPX6/iKANsVLbB3XgtfQXzG1nS4HmSavN92qVY= +github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY= +github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +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-multierror v1.1.0/go.mod h1:spPvp8C1qA32ftKqdAHm4hHTbPw+vmowP0z+KUhOZdA= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU= +github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk= +github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY= +github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c= +github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= +github.com/hashicorp/hcl v1.0.1-vault-5 h1:kI3hhbbyzr4dldA8UdTb7ZlVVlI2DACdCfz31RPDgJM= +github.com/hashicorp/hcl v1.0.1-vault-5/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM= +github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= +github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= +github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= +github.com/ipfs/bbloom v0.0.4 h1:Gi+8EGJ2y5qiD5FbsbpX/TMNcJw8gSqr7eyjHa4Fhvs= +github.com/ipfs/bbloom v0.0.4/go.mod h1:cS9YprKXpoZ9lT0n/Mw/a6/aFV6DTjTLYHeA+gyqMG0= +github.com/ipfs/go-block-format v0.2.0 h1:ZqrkxBA2ICbDRbK8KJs/u0O3dlp6gmAuuXUJNiW1Ycs= +github.com/ipfs/go-block-format v0.2.0/go.mod h1:+jpL11nFx5A/SPpsoBn6Bzkra/zaArfSmsknbPMYgzM= +github.com/ipfs/go-cid v0.4.1 h1:A/T3qGvxi4kpKWWcPC/PgbvDA2bjVLO7n4UeVwnbs/s= +github.com/ipfs/go-cid v0.4.1/go.mod h1:uQHwDeX4c6CtyrFwdqyhpNcxVewur1M7l7fNU7LKwZk= +github.com/ipfs/go-datastore v0.6.0 h1:JKyz+Gvz1QEZw0LsX1IBn+JFCJQH4SJVFtM4uWU0Myk= +github.com/ipfs/go-datastore v0.6.0/go.mod h1:rt5M3nNbSO/8q1t4LNkLyUwRs8HupMeN/8O4Vn9YAT8= +github.com/ipfs/go-ipfs-blockstore v1.3.1 h1:cEI9ci7V0sRNivqaOr0elDsamxXFxJMMMy7PTTDQNsQ= +github.com/ipfs/go-ipfs-blockstore v1.3.1/go.mod h1:KgtZyc9fq+P2xJUiCAzbRdhhqJHvsw8u2Dlqy2MyRTE= +github.com/ipfs/go-ipfs-ds-help v1.1.1 h1:B5UJOH52IbcfS56+Ul+sv8jnIV10lbjLF5eOO0C66Nw= +github.com/ipfs/go-ipfs-ds-help v1.1.1/go.mod h1:75vrVCkSdSFidJscs8n4W+77AtTpCIAdDGAwjitJMIo= +github.com/ipfs/go-ipfs-util v0.0.3 h1:2RFdGez6bu2ZlZdI+rWfIdbQb1KudQp3VGwPtdNCmE0= +github.com/ipfs/go-ipfs-util v0.0.3/go.mod h1:LHzG1a0Ig4G+iZ26UUOMjHd+lfM84LZCrn17xAKWBvs= +github.com/ipfs/go-ipld-cbor v0.1.0 h1:dx0nS0kILVivGhfWuB6dUpMa/LAwElHPw1yOGYopoYs= +github.com/ipfs/go-ipld-cbor v0.1.0/go.mod h1:U2aYlmVrJr2wsUBU67K4KgepApSZddGRDWBYR0H4sCk= +github.com/ipfs/go-ipld-format v0.6.0 h1:VEJlA2kQ3LqFSIm5Vu6eIlSxD/Ze90xtc4Meten1F5U= +github.com/ipfs/go-ipld-format v0.6.0/go.mod h1:g4QVMTn3marU3qXchwjpKPKgJv+zF+OlaKMyhJ4LHPg= +github.com/ipfs/go-log v1.0.5 h1:2dOuUCB1Z7uoczMWgAyDck5JLb72zHzrMnGnCNNbvY8= +github.com/ipfs/go-log v1.0.5/go.mod h1:j0b8ZoR+7+R99LD9jZ6+AJsrzkPbSXbZfGakb5JPtIo= +github.com/ipfs/go-log/v2 v2.1.3/go.mod h1:/8d0SH3Su5Ooc31QlL1WysJhvyOTDCjcCZ9Axpmri6g= +github.com/ipfs/go-log/v2 v2.5.1 h1:1XdUzF7048prq4aBjDQQ4SL5RxftpRGdXhNRwKSAlcY= +github.com/ipfs/go-log/v2 v2.5.1/go.mod h1:prSpmC1Gpllc9UYWxDiZDreBYw7zp4Iqp1kOLU9U5UI= +github.com/ipfs/go-metrics-interface v0.0.1 h1:j+cpbjYvu4R8zbleSs36gvB7jR+wsL2fGD6n0jO4kdg= +github.com/ipfs/go-metrics-interface v0.0.1/go.mod h1:6s6euYU4zowdslK0GKHmqaIZ3j/b/tL7HTWtJ4VPgWY= +github.com/jbenet/go-cienv v0.1.0/go.mod h1:TqNnHUmJgXau0nCzC7kXWeotg3J9W34CUv5Djy1+FlA= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= +github.com/jbenet/goprocess v0.1.4 h1:DRGOFReOMqqDNXwW70QkacFW0YN9QnwLV0Vqk+3oU0o= +github.com/jbenet/goprocess v0.1.4/go.mod h1:5yspPrukOVuOLORacaBi858NqyClJPQxYZlqdZVfqY4= +github.com/jedisct1/go-minisign v0.0.0-20230811132847-661be99b8267 h1:TMtDYDHKYY15rFihtRfck/bfFqNfvcabqvXAFQfAUpY= +github.com/jedisct1/go-minisign v0.0.0-20230811132847-661be99b8267/go.mod h1:h1nSAbGFqGVzn6Jyl1R/iCcBUHN4g+gW1u9CoBTrb9E= +github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= +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/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= +github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= +github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU= +github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/letsencrypt/boulder v0.0.0-20240620165639-de9c06129bec h1:2tTW6cDth2TSgRbAhD7yjZzTQmcN25sDRPEeinR51yQ= +github.com/letsencrypt/boulder v0.0.0-20240620165639-de9c06129bec/go.mod h1:TmwEoGCwIti7BCeJ9hescZgRtatxRE+A72pCoPfmcfk= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +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/mattn/go-localereader v0.0.2-0.20220822084749-2491eb6c1c75 h1:P8UmIzZMYDR+NGImiFvErt6VWfIRPuGM+vyjiEdkmIw= +github.com/mattn/go-localereader v0.0.2-0.20220822084749-2491eb6c1c75/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-mastodon v0.0.9 h1:zAlQF0LMumKPQLNR7dZL/YVCrvr4iP6ayyzxTR3vsSw= +github.com/mattn/go-mastodon v0.0.9/go.mod h1:8YkqetHoAVEktRkK15qeiv/aaIMfJ/Gc89etisPZtHU= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM= +github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8= +github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= +github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= +github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= +github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/mango v0.1.0 h1:DZQK45d2gGbql1arsYA4vfg4d7I9Hfx5rX/GCmzsAvI= +github.com/muesli/mango v0.1.0/go.mod h1:5XFpbC8jY5UUv89YQciiXNlbi+iJgt29VDC5xbzrLL4= +github.com/muesli/mango-cobra v1.2.0 h1:DQvjzAM0PMZr85Iv9LIMaYISpTOliMEg+uMFtNbYvWg= +github.com/muesli/mango-cobra v1.2.0/go.mod h1:vMJL54QytZAJhCT13LPVDfkvCUJ5/4jNUKF/8NC2UjA= +github.com/muesli/mango-pflag v0.1.0 h1:UADqbYgpUyRoBja3g6LUL+3LErjpsOwaC9ywvBWe7Sg= +github.com/muesli/mango-pflag v0.1.0/go.mod h1:YEQomTxaCUp8PrbhFh10UfbhbQrM/xJ4i2PB8VTLLW0= +github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8= +github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/multiformats/go-base32 v0.1.0 h1:pVx9xoSPqEIQG8o+UbAe7DNi51oej1NtK+aGkbLYxPE= +github.com/multiformats/go-base32 v0.1.0/go.mod h1:Kj3tFY6zNr+ABYMqeUNeGvkIC/UYgtWibDcT0rExnbI= +github.com/multiformats/go-base36 v0.2.0 h1:lFsAbNOGeKtuKozrtBsAkSVhv1p9D0/qedU9rQyccr0= +github.com/multiformats/go-base36 v0.2.0/go.mod h1:qvnKE++v+2MWCfePClUEjE78Z7P2a1UV0xHgWc0hkp4= +github.com/multiformats/go-multibase v0.2.0 h1:isdYCVLvksgWlMW9OZRYJEa9pZETFivncJHmHnnd87g= +github.com/multiformats/go-multibase v0.2.0/go.mod h1:bFBZX4lKCA/2lyOFSAoKH5SS6oPyjtnzK/XTFDPkNuk= +github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7BFvVU9RSh+U= +github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM= +github.com/multiformats/go-varint v0.0.7 h1:sWSGR+f/eu5ABZA2ZpYKBILXTTs9JWpdEM/nEGOHFS8= +github.com/multiformats/go-varint v0.0.7/go.mod h1:r8PUYw/fD/SjBCiKOoDlGF6QawOELpZAu9eioSos/OU= +github.com/ofabry/go-callvis v0.7.1 h1:Lu5YwEUUr+CK98nFqA77/nib0p6NCRjkJ9es/770p1U= +github.com/ofabry/go-callvis v0.7.1/go.mod h1:4O2V2f7pJTEp3n8DaHt6/P6N39D6uDJGdMInZZ/nI38= +github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= +github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= +github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= +github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs= +github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= +github.com/pborman/getopt v0.0.0-20180811024354-2b5b3bfb099b/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= +github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= +github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= +github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= +github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= +github.com/pjbgf/sha1cd v0.3.0 h1:4D5XXmUUBUl/xQ6IjCkEAbqXskkq/4O7LmGn0AqMDs4= +github.com/pjbgf/sha1cd v0.3.0/go.mod h1:nZ1rrWOcGJ5uZgEEVL1VUM9iRQiZvWdbZjkKyFzPPsI= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f h1:VXTQfuJj9vKR4TCkEuWIckKvdHFeJH/huIFJ9/cXOB0= +github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f/go.mod h1:/zvteZs/GwLtCgZ4BL6CBsk9IKIlexP43ObX9AxTqTw= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sagikazarmark/locafero v0.6.0 h1:ON7AQg37yzcRPU69mt7gwhFEBwxI6P9T4Qu3N51bwOk= +github.com/sagikazarmark/locafero v0.6.0/go.mod h1:77OmuIc6VTraTXKXIs/uvUxKGUXjE1GbemJYHqdNjX0= +github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= +github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= +github.com/sassoftware/relic v7.2.1+incompatible h1:Pwyh1F3I0r4clFJXkSI8bOyJINGqpgjJU3DYAZeI05A= +github.com/sassoftware/relic v7.2.1+incompatible/go.mod h1:CWfAxv73/iLZ17rbyhIEq3K9hs5w6FpNMdUT//qR+zk= +github.com/scylladb/go-set v1.0.3-0.20200225121959-cc7b2070d91e h1:7q6NSFZDeGfvvtIRwBrU/aegEYJYmvev0cHAwo17zZQ= +github.com/scylladb/go-set v1.0.3-0.20200225121959-cc7b2070d91e/go.mod h1:DkpGd78rljTxKAnTDPFqXSGxvETQnJyuSOQwsHycqfs= +github.com/secure-systems-lab/go-securesystemslib v0.8.0 h1:mr5An6X45Kb2nddcFlbmfHkLguCE9laoZCUzEEpIZXA= +github.com/secure-systems-lab/go-securesystemslib v0.8.0/go.mod h1:UH2VZVuJfCYR8WgMlCU1uFsOUU+KeyrTWcSS73NBOzU= +github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= +github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= +github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= +github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= +github.com/sigstore/cosign/v2 v2.4.1 h1:b8UXEfJFks3hmTwyxrRNrn6racpmccUycBHxDMkEPvU= +github.com/sigstore/cosign/v2 v2.4.1/go.mod h1:GvzjBeUKigI+XYnsoVQDmMAsMMc6engxztRSuxE+x9I= +github.com/sigstore/protobuf-specs v0.3.2 h1:nCVARCN+fHjlNCk3ThNXwrZRqIommIeNKWwQvORuRQo= +github.com/sigstore/protobuf-specs v0.3.2/go.mod h1:RZ0uOdJR4OB3tLQeAyWoJFbNCBFrPQdcokntde4zRBA= +github.com/sigstore/rekor v1.3.6 h1:QvpMMJVWAp69a3CHzdrLelqEqpTM3ByQRt5B5Kspbi8= +github.com/sigstore/rekor v1.3.6/go.mod h1:JDTSNNMdQ/PxdsS49DJkJ+pRJCO/83nbR5p3aZQteXc= +github.com/sigstore/sigstore v1.8.9 h1:NiUZIVWywgYuVTxXmRoTT4O4QAGiTEKup4N1wdxFadk= +github.com/sigstore/sigstore v1.8.9/go.mod h1:d9ZAbNDs8JJfxJrYmulaTazU3Pwr8uLL9+mii4BNR3w= +github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/skeema/knownhosts v1.3.0 h1:AM+y0rI04VksttfwjkSTNQorvGqmwATnvnAHpSgc0LY= +github.com/skeema/knownhosts v1.3.0/go.mod h1:sPINvnADmT/qYH1kfv+ePMmOBTH6Tbl7b5LvTDjFK7M= +github.com/slack-go/slack v0.16.0 h1:khp/WCFv+Hb/B/AJaAwvcxKun0hM6grN0bUZ8xG60P8= +github.com/slack-go/slack v0.16.0/go.mod h1:hlGi5oXA+Gt+yWTPP0plCdRKmjsDxecdHxYQdlMQKOw= +github.com/smartystreets/assertions v1.2.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo= +github.com/smartystreets/goconvey v1.7.2/go.mod h1:Vw0tHAZW6lzCRk3xgdin6fKYcG+G3Pg9vgXWeJpQFMM= +github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= +github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= +github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= +github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= +github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= +github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= +github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w= +github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= +github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= +github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= +github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= +github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg= +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/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +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.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/theupdateframework/go-tuf v0.7.0 h1:CqbQFrWo1ae3/I0UCblSbczevCCbS31Qvs5LdxRWqRI= +github.com/theupdateframework/go-tuf v0.7.0/go.mod h1:uEB7WSY+7ZIugK6R1hiBMBjQftaFzn7ZCDJcp1tCUug= +github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399 h1:e/5i7d4oYZ+C1wj2THlRK+oAhjeS/TRQwMfkIuet3w0= +github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399/go.mod h1:LdwHTNJT99C5fTAzDz0ud328OgXz+gierycbcIx2fRs= +github.com/tomnomnom/linkheader v0.0.0-20180905144013-02ca5825eb80 h1:nrZ3ySNYwJbSpD6ce9duiP+QkD3JuLCcWkdaehUS/3Y= +github.com/tomnomnom/linkheader v0.0.0-20180905144013-02ca5825eb80/go.mod h1:iFyPdL66DjUD96XmzVL3ZntbzcflLnznH0fr99w5VqE= +github.com/ulikunitz/xz v0.5.12 h1:37Nm15o69RwBkXM0J6A5OlE67RZTfzUxTj8fB3dfcsc= +github.com/ulikunitz/xz v0.5.12/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= +github.com/urfave/cli v1.22.10/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= +github.com/vbatts/tar-split v0.11.6 h1:4SjTW5+PU11n6fZenf2IPoV8/tz3AaYHMWjf23envGs= +github.com/vbatts/tar-split v0.11.6/go.mod h1:dqKNtesIOr2j2Qv3W/cHjnvk9I8+G7oAkFDFN6TCBEI= +github.com/wagoodman/go-partybus v0.0.0-20230516145632-8ccac152c651 h1:jIVmlAFIqV3d+DOxazTR9v+zgj8+VYuQBzPgBZvWBHA= +github.com/wagoodman/go-partybus v0.0.0-20230516145632-8ccac152c651/go.mod h1:b26F2tHLqaoRQf8DywqzVaV1MQ9yvjb0OMcNl7Nxu20= +github.com/wagoodman/go-progress v0.0.0-20220614130704-4b1c25a33c7c h1:gFwUKtkv6QzQsFdIjvPqd0Qdw42DHUEbbUdiUTI1uco= +github.com/wagoodman/go-progress v0.0.0-20220614130704-4b1c25a33c7c/go.mod h1:jLXFoL31zFaHKAAyZUh+sxiTDFe1L1ZHrcK2T1itVKA= +github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0/go.mod h1:x6AKhvSSexNrVSrViXSHUEbICjmGXhtgABaHIySUSGw= +github.com/whyrusleeping/cbor-gen v0.1.3-0.20240731173018-74d74643234c h1:Jmc9fHbd0LKFmS5CkLgczNUyW36UbiyvbHCG9xCTyiw= +github.com/whyrusleeping/cbor-gen v0.1.3-0.20240731173018-74d74643234c/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so= +github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= +github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= +github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= +github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +gitlab.com/digitalxero/go-conventional-commit v1.0.7 h1:8/dO6WWG+98PMhlZowt/YjuiKhqhGlOCwlIV8SqqGh8= +gitlab.com/digitalxero/go-conventional-commit v1.0.7/go.mod h1:05Xc2BFsSyC5tKhK0y+P3bs0AwUtNuTp+mTpbCU/DZ0= +gitlab.com/gitlab-org/api/client-go v0.128.0 h1:Wvy1UIuluKemubao2k8EOqrl3gbgJ1PVifMIQmg2Da4= +gitlab.com/gitlab-org/api/client-go v0.128.0/go.mod h1:bYC6fPORKSmtuPRyD9Z2rtbAjE7UeNatu2VWHRf4/LE= +go.mongodb.org/mongo-driver v1.14.0 h1:P98w8egYRjYe3XDjxhYJagTokP/H6HzlsnojRgZRd80= +go.mongodb.org/mongo-driver v1.14.0/go.mod h1:Vzb0Mk/pa7e6cWw85R4F/endUC3u0U9jGcNU603k65c= +go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= +go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/contrib/detectors/gcp v1.35.0 h1:bGvFt68+KTiAKFlacHW6AhA56GF2rS0bdD3aJYEnmzA= +go.opentelemetry.io/contrib/detectors/gcp v1.35.0/go.mod h1:qGWP8/+ILwMRIUf9uIVLloR1uo5ZYAslM4O6OqUi1DA= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 h1:x7wzEgXfnzJcHDwStJT+mxOz4etr2EcexjqhBvmoakw= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0/go.mod h1:rg+RlpR5dKwaS95IyyZqj5Wd4E13lk/msnTS0Xl9lJM= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ= +go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ= +go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y= +go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M= +go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE= +go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY= +go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg= +go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o= +go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w= +go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs= +go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc= +go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= +go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= +go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= +go.uber.org/goleak v1.1.11-0.20210813005559-691160354723/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= +go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU= +go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA= +go.uber.org/zap v1.16.0/go.mod h1:MA8QOfq0BHJwdXa996Y4dYkAqRKB8/1K1QMMZVaNZjQ= +go.uber.org/zap v1.19.1/go.mod h1:j3DNczoxDZroyBnOT1L/Q79cfUMGZxlv/9dzN7SM1rI= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +gocloud.dev v0.41.0 h1:qBKd9jZkBKEghYbP/uThpomhedK5s2Gy6Lz7h/zYYrM= +gocloud.dev v0.41.0/go.mod h1:IetpBcWLUwroOOxKr90lhsZ8vWxeSkuszBnW62sbcf0= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190426145343-a29dc8fdc734/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= +golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= +golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c h1:7dEasQXItcW1xKJ2+gg5VOiBnqWrJc+rq0DPKyvvdbY= +golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c/go.mod h1:NQtJDoLvd6faHhE7m4T/1IY708gDefGGjR/iUW8yQQ8= +golang.org/x/image v0.15.0 h1:kOELfmgrmJlw4Cdb7g/QGuB3CvDrXbqEIww/pNtNBm8= +golang.org/x/image v0.15.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +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/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= +golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +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.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= +golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= +golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.29.0 h1:WdYw2tdTK1S8olAzWHdgeqfy+Mtm9XNhv/xJsY65d98= +golang.org/x/oauth2 v0.29.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/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.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= +golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/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-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/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.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= +golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +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.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= +golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o= +golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw= +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.6/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.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= +golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= +golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= +golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +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/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.17.0/go.mod h1:xsh6VxdV005rRVaS6SSAf9oiAqljS7UZUacMZ8Bnsps= +golang.org/x/tools v0.32.0 h1:Q7N1vhpkQv7ybVzLFtTjvQya2ewbwNDZzUgfXGqtMWU= +golang.org/x/tools v0.32.0/go.mod h1:ZxrU41P/wAbZD8EDa6dDCa6XfpkhJ7HFMjHJXfBDu8s= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY= +golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= +google.golang.org/api v0.228.0 h1:X2DJ/uoWGnY5obVjewbp8icSL5U4FzuCfy9OjbLSnLs= +google.golang.org/api v0.228.0/go.mod h1:wNvRS1Pbe8r4+IfBIniV8fwCpGwTrYa+kMUDiC5z5a4= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/genproto v0.0.0-20250324211829-b45e905df463 h1:qEFnJI6AnfZk0NNe8YTyXQh5i//Zxi4gBHwRgp76qpw= +google.golang.org/genproto v0.0.0-20250324211829-b45e905df463/go.mod h1:SqIx1NV9hcvqdLHo7uNZDS5lrUJybQ3evo3+z/WBfA0= +google.golang.org/genproto/googleapis/api v0.0.0-20250324211829-b45e905df463 h1:hE3bRWtU6uceqlh4fhrSnUyjKHMKB9KrTLLG+bc0ddM= +google.golang.org/genproto/googleapis/api v0.0.0-20250324211829-b45e905df463/go.mod h1:U90ffi8eUL9MwPcrJylN5+Mk2v3vuPDptd5yyNUiRR8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 h1:e0AIkUUhxyBKh6ssZNrAMeqhA7RKUj42346d1y02i2g= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= +google.golang.org/grpc v1.71.0 h1:kF77BGdPTQ4/JZWMlb9VpJ5pa25aqvVqogsxNHHdeBg= +google.golang.org/grpc v1.71.0/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= +google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= +google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk= +gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/mail.v2 v2.3.1 h1:WYFn/oANrAGP2C0dcV6/pbkPzv8yGzqTjPmTeO7qoXk= +gopkg.in/mail.v2 v2.3.1/go.mod h1:htwXN1Qh09vZJ1NVKxQqHPBaCBbzKhp5GzuJEA4VJWw= +gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= +gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +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.0-20210107192922-496545a6307b/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= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= +lukechampine.com/blake3 v1.2.1 h1:YuqqRuaqsGV71BV/nm9xlI0MKUv4QC54jQnBChWbGnI= +lukechampine.com/blake3 v1.2.1/go.mod h1:0OFRp7fBtAylGVCO40o87sbupkyIGgbpv1+M1k1LM6k= +sigs.k8s.io/kind v0.24.0 h1:g4y4eu0qa+SCeKESLpESgMmVFBebL0BDa6f777OIWrg= +sigs.k8s.io/kind v0.24.0/go.mod h1:t7ueEpzPYJvHA8aeLtI52rtFftNgUYUaCwvxjk7phfw= +sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= +sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= +software.sslmate.com/src/go-pkcs12 v0.5.0 h1:EC6R394xgENTpZ4RltKydeDUjtlM5drOYIG9c6TVj2M= +software.sslmate.com/src/go-pkcs12 v0.5.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI=